Drop the @remark42/api package (#2172)

* Drop the @remark42/api package

It cannot authenticate anyone: clients/auth.ts exposes only anonymous,
email and telegram, with no OAuth method, and the fetcher never sets
credentials so its cookie auth cannot work cross-origin. Nothing in the
repo consumes it, no third-party consumer exists, and npm has served an
alpha from July 2022 that CI never publishes.

* Drop the removed workflow from the pnpm pinning checklist

frontend/CLAUDE.md still counted ci-frontend-api.yml among the places the
pnpm version is pinned, and stated a fixed total that no longer holds.
This commit is contained in:
Dmitry Verkhoturov
2026-08-19 23:28:52 -05:00
committed by GitHub
parent 439ccfa83c
commit d370b78613
33 changed files with 8 additions and 2838 deletions
-12
View File
@@ -37,18 +37,6 @@ updates:
dependency-type: "production"
"NPM modules updates for tests":
dependency-type: "development"
- package-ecosystem: "npm"
directory: "/frontend/packages/api"
open-pull-requests-limit: 0
ignore:
- dependency-name: "*"
schedule:
interval: "monthly"
groups:
"NPM modules updates":
dependency-type: "production"
"NPM modules updates for tests":
dependency-type: "development"
- package-ecosystem: "npm"
directory: "/frontend/e2e"
open-pull-requests-limit: 0
-131
View File
@@ -1,131 +0,0 @@
name: "@remark42/api"
on:
push:
branches:
- master
paths:
- ".github/workflows/ci-frontend-api.yml"
- "frontend/packages/**"
- "!**.md"
pull_request:
paths:
- ".github/workflows/ci-frontend-api.yml"
- "frontend/packages/**"
- "!**.md"
jobs:
type-check:
name: Type check
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [ 20 ]
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: Install node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: ./frontend
- name: Run type check
run: pnpm type-check:api
working-directory: ./frontend
lint:
name: Lint
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [ 20 ]
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: Install node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: ./frontend
- name: Run linters
run: pnpm lint:api
working-directory: ./frontend/
test:
name: Tests & Coverage
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: [ 20 ]
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
with:
version: 10.10.0
run_install: false
- name: Install node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: ./frontend
- name: Test & Coverage
run: pnpm coverage:api
working-directory: ./frontend
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
working-directory: ./frontend
codecov_yml_path: ./frontend/apps/remark42/codecov.yml
+7 -5
View File
@@ -2,7 +2,7 @@
Non-obvious things that bit us during the pnpm 8→10 / node 16→20 migration (PR #2091, fixing #2085). Read before bumping dependencies or the node/pnpm toolchain again.
## The node/pnpm version is pinned in ten places, not one
## The node/pnpm version is pinned in many places, not one
CI staying green does **not** mean every pin is consistent — `.nvmrc` in particular is never read by CI, so it can silently drift. After changing the node or pnpm version, grep the whole repo and update every one of these, not just the ones CI exercises:
@@ -10,8 +10,8 @@ CI staying green does **not** mean every pin is consistent — `.nvmrc` in parti
- `frontend/Dockerfile.e2e``FROM mcr.microsoft.com/playwright:vX.Y.Z-noble` **and** `corepack prepare pnpm@X.Y.Z`
- `site/Dockerfile`, `site/Dockerfile.dev``FROM node:X-alpine` (site uses yarn, not pnpm)
- `frontend/.nvmrc`, `site/.nvmrc` — not read by CI at all; only matters to a human running `nvm use` locally. This is the one that drifted unnoticed: it sat at `16` through the whole node-20 migration because nothing red ever pointed at it.
- Every `package.json`'s `packageManager` field (`frontend/package.json`, `frontend/apps/remark42/package.json`, `frontend/packages/api/package.json``frontend/e2e/package.json` has none) and `frontend/apps/remark42/package.json`'s `engines` block
- `pnpm/action-setup@vN` blocks in `.github/workflows/ci-frontend.yml` (5), `ci-frontend-api.yml` (3), `release.yml` (2) — pin `version:` to the **exact** patch (e.g. `10.10.0`), matching `packageManager`, not just the major. A floating major here is silent in CI (it just resolves to whatever the latest patch is at run time) but breaks the "Dockerfile and CI use the same pnpm" guarantee.
- Every `package.json`'s `packageManager` field (`frontend/package.json`, `frontend/apps/remark42/package.json``frontend/e2e/package.json` has none) and `frontend/apps/remark42/package.json`'s `engines` block
- `pnpm/action-setup@vN` blocks in `.github/workflows/ci-frontend.yml` (5) and `release.yml` (2) — pin `version:` to the **exact** patch (e.g. `10.10.0`), matching `packageManager`, not just the major. A floating major here is silent in CI (it just resolves to whatever the latest patch is at run time) but breaks the "Dockerfile and CI use the same pnpm" guarantee.
- `frontend/e2e/package.json`'s `@playwright/test`/`playwright` versions must match `frontend/Dockerfile.e2e`'s base image tag exactly, or the e2e container's bundled browser revision mismatches what the npm package expects.
When bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `engines` field — it's separate from `packageManager` and won't update itself.
@@ -26,9 +26,11 @@ A few deps needed pinning specifically because of pnpm 10's hoisting changes, no
If a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency.
## msw 1→2 needed a real migration, not just a version bump
## node 20's native fetch requires absolute URLs in tests
`frontend/packages/api` test mocks (`tests/test-utils.ts`) moved from msw's `rest` API to `http`/`HttpResponse`. Also: test base URLs had to become absolute, and a jsdom base URL had to be set, because node 20's native `fetch` (unlike node 16/18's polyfilled fetch) requires an absolute URL — relative request URLs in tests started failing silently otherwise.
Unlike the polyfilled fetch in node 16 and 18, it rejects relative request URLs, and the failure is
silent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and
a jsdom base URL set.
## Held-back majors
+1 -5
View File
@@ -11,11 +11,7 @@
"scripts": {
"prepare": "if [ -z \"$CI\" ]; then cd .. && husky install frontend/.husky; else echo \"Skip Husky Hooks\"; fi",
"lint-staged": "lint-staged",
"dev:app": "turbo run dev --filter=@remark42/app",
"test:api": "turbo run test --filter=@remark42/api",
"coverage:api": "turbo run coverage --filter=@remark42/api",
"type-check:api": "turbo run type-check --filter=@remark42/api",
"lint:api": "turbo run lint --filter=@remark42/api"
"dev:app": "turbo run dev --filter=@remark42/app"
},
"packageManager": "pnpm@10.10.0",
"devDependencies": {
-2
View File
@@ -1,2 +0,0 @@
dist
coverage
-22
View File
@@ -1,22 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:@typescript-eslint/strict',
'plugin:prettier/recommended',
],
overrides: [],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.eslint.json',
},
plugins: ['@typescript-eslint'],
}
-5
View File
@@ -1,5 +0,0 @@
node_modules
coverage
dist
**/*.js
**/*.d.ts
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
'./**/*.ts': ['pnpm lint-staged:lint'],
}
-17
View File
@@ -1,17 +0,0 @@
coverage/**
dist/**
node_modules/**
src/**
test/**
.editorconfig
.eslintrc.cjs
.prettierrc
files.txt
jest.config.ts
pnpm-lock.yaml
postpublish.sh
prepublish.sh
tsconfig.common.json
tsconfig.dev.json
tsconfig.json
-7
View File
@@ -1,7 +0,0 @@
{
"semi": false,
"printWidth": 100,
"quoteProps": "consistent",
"singleQuote": true,
"trailingComma": "es5"
}
-11
View File
@@ -1,11 +0,0 @@
# @remark42/api
Implementation of API methods for Remark42
## Development
- If you don't have `pnpm` installed run `npm i -g pnpm@8`
- Install dependencies `pnpm i`
- Run development mode with `pnpm run dev`
- Build lib with `pnpm run build`
- Run tests with `pnpm run test`
-134
View File
@@ -1,134 +0,0 @@
import type { ClientParams, User } from './index'
import { API_BASE } from '../consts'
import { createFetcher } from '../lib/fetcher'
export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m'
export interface BlockUserResponse {
block: boolean
site_id: string
user_id: string
}
export function createAdminClient({ siteId, baseUrl }: ClientParams) {
const fetcher = createFetcher(siteId, `${baseUrl}${API_BASE}`)
async function toggleUserVerification(id: string, verified: 0 | 1): Promise<void> {
return fetcher.put(`/verify/${id}`, { verified })
}
async function toggleCommentPin(id: string, pinned: 0 | 1): Promise<void> {
return fetcher.put(`/pin/${id}`, { pinned })
}
async function toggleCommenting(url: string, ro: 0 | 1): Promise<void> {
return fetcher.put('/readonly', { url, ro })
}
async function toggleUserBlock(id: string, ttl?: BlockTTL): Promise<BlockUserResponse> {
const params = ttl
? {
block: 1,
ttl: ttl === 'permanently' ? 0 : ttl,
}
: { block: 0 }
return fetcher.put<BlockUserResponse>(`/user/${id}`, params)
}
/**
* Request list of blocked users
*/
async function getBlockedUsers(): Promise<User[]> {
return fetcher.get<User[]>('/blocked')
}
/**
* Block user from commenting
* @param id user ID
* @param ttl block duration
*/
async function blockUser(id: string, ttl: BlockTTL): Promise<BlockUserResponse> {
return toggleUserBlock(id, ttl)
}
/**
* Unblock user from commenting
* @param id user ID
*/
async function unblockUser(id: string): Promise<BlockUserResponse> {
return toggleUserBlock(id)
}
/**
* Mark user as verified
* @param id user ID
*/
async function verifyUser(id: string): Promise<void> {
return toggleUserVerification(id, 1)
}
/**
* Mark user as unverified
* @param id user ID
*/
async function unverifyUser(id: string): Promise<void> {
return toggleUserVerification(id, 0)
}
/**
* Approve request to remove user data
* @param token token from email
*/
async function approveRemovingRequest(token: string): Promise<void> {
return fetcher.get('/deleteme', { token })
}
/**
* Mark comment as pinned
* @param id comment ID
*/
async function pinComment(id: string): Promise<void> {
return toggleCommentPin(id, 1)
}
/**
* Mark comment as unpinned
* @param id comment ID
*/
async function unpinComment(id: string): Promise<void> {
return toggleCommentPin(id, 0)
}
/**
* Remove comment
* @param url page URL
* @param id comment ID
*/
async function removeComment(url: string, id: string): Promise<void> {
return fetcher.delete(`/comment/${id}`, { url })
}
/**
* Enable commenting on a page
* @param url page URL
*/
async function enableCommenting(url: string) {
return toggleCommenting(url, 1)
}
/**
* Disable commenting on a page
* @param url page URL
*/
async function disableCommenting(url: string) {
return toggleCommenting(url, 0)
}
return {
getBlockedUsers,
blockUser,
unblockUser,
verifyUser,
unverifyUser,
approveRemovingRequest,
pinComment,
unpinComment,
removeComment,
enableCommenting,
disableCommenting,
}
}
-47
View File
@@ -1,47 +0,0 @@
import type { ClientParams, User } from './index'
import { createFetcher } from '../lib/fetcher'
export function createAuthClient({ siteId, baseUrl }: ClientParams) {
const fetcher = createFetcher(siteId, `${baseUrl}/auth`)
async function anonymous(user: string): Promise<User> {
return fetcher.get<User>('/anonymous/login', { user, aud: siteId })
}
async function email(email: string, username: string): Promise<(token: string) => Promise<User>> {
const EMAIL_SIGNIN_ENDPOINT = '/email/login'
await fetcher.get<undefined>(EMAIL_SIGNIN_ENDPOINT, { address: email, user: username })
return function tokenVerification(token: string): Promise<User> {
return fetcher.get<User>(EMAIL_SIGNIN_ENDPOINT, { token })
}
}
async function telegram() {
const TELEGRAM_SIGNIN_ENDPOINT = '/telegram/login'
const { bot, token } = await fetcher.get<{ bot: string; token: string }>(
TELEGRAM_SIGNIN_ENDPOINT
)
return {
bot,
token,
verify() {
return fetcher.get(TELEGRAM_SIGNIN_ENDPOINT, { token })
},
}
}
async function logout(): Promise<void> {
return fetcher.get<void>('/logout')
}
return {
anonymous,
email,
telegram,
logout,
}
}
-57
View File
@@ -1,57 +0,0 @@
import { createAdminClient } from './admin'
import { createAuthClient } from './auth'
import { createPublicClient } from './public'
export interface User {
id: string
name: string
/** url to avatar */
picture: string
admin: boolean
block: boolean
verified: boolean
/** subscription to email notification */
email_subscription?: boolean
/** users with Patreon auth can have paid status */
paid_sub?: boolean
}
export type OAuthProvider =
| 'apple'
| 'facebook'
| 'twitter'
| 'google'
| 'yandex'
| 'github'
| 'microsoft'
| 'patreon'
| 'discord'
| 'telegram'
| 'dev'
export type FormProvider = 'email' | 'anonymous'
export type Provider = OAuthProvider | FormProvider
export interface ClientParams {
siteId: string
baseUrl: string
}
export interface Client {
admin: ReturnType<typeof createAdminClient>
auth: ReturnType<typeof createAuthClient>
public: ReturnType<typeof createPublicClient>
}
let client: Client | undefined
export function createClient(params: ClientParams): Client {
if (client === undefined) {
client = {
auth: createAuthClient(params),
admin: createAdminClient(params),
public: createPublicClient(params),
}
}
return client
}
-160
View File
@@ -1,160 +0,0 @@
import type { ClientParams, Provider, User } from './index'
import { createFetcher } from '../lib/fetcher'
import { API_BASE } from '../consts'
export interface Config {
version: string
auth_providers: Provider[]
edit_duration: number
admin_edit: boolean
max_comment_size: number
admins: string[]
admin_email: string
low_score: number
critical_score: number
positive_score: boolean
readonly_age: number
max_image_size: number
simple_view: boolean
anon_vote: boolean
email_notifications: boolean
telegram_notifications: boolean
emoji_enabled: boolean
}
export interface Comment {
/** comment id */
id: string
/** parent id */
pid: string
/** comment text, after md processing */
text: string
/** original comment text */
orig?: string
user: User
locator: {
/** site id */
site: string
/** page url */
url: string
}
score: number
voted_ips: { Timestamp: string; Value: boolean }[]
/**
* vote delta,
* if user hasn't voted delta will be 0,
* -1/+1 for downvote/upvote
*/
vote: 0 | 1 | -1
/** comment controversy */
controversy?: number
/** pointer to have empty default in json response */
edit?: {
time: string
summary: string
}
/** timestamp */
time: string
pin?: boolean
delete?: boolean
/** page title */
title?: string
}
export interface CommentsTree {
comment: Comment
replies: Comment[]
}
export interface CommentPayload {
title?: string
pid?: string
text: string
}
export type Sort = '-active' | '+active'
export interface GetUserCommentsParams {
url: string
sort?: Sort
limit?: number
skip?: number
}
export type Vote = -1 | 1
export function createPublicClient({ siteId: site, baseUrl }: ClientParams) {
const fetcher = createFetcher(site, `${baseUrl}${API_BASE}`)
const authFetcher = createFetcher(site, `${baseUrl}/auth`)
/**
* Get server config
*/
async function getConfig(): Promise<Config> {
return fetcher.get('/config')
}
/**
* Get current authorized user.
* Probes /auth/status first (always 200, no console 401), then fetches /user only if logged in.
*/
async function getUser(): Promise<User | null> {
const status = await authFetcher.get<{ status: string }>('/status').catch(() => null)
if (status?.status !== 'logged in') {
return null
}
return fetcher.get<User | null>('/user').catch(() => null)
}
/**
* Get comments
*/
async function getComments(url: string): Promise<CommentsTree>
async function getComments(params: GetUserCommentsParams): Promise<Comment[]>
async function getComments(
params: string | GetUserCommentsParams
): Promise<Comment[] | CommentsTree> {
if (typeof params === 'string') {
return fetcher.get('/comments', { url: params })
}
return fetcher.get<CommentsTree>('/find', { ...params, format: 'tree' })
}
/**
* Add new comment
*/
async function addComment(url: string, payload: CommentPayload): Promise<Comment> {
const locator = { site, url }
return fetcher.post('/comment', {}, { ...payload, locator })
}
/**
* Update comment
*/
async function updateComment(url: string, id: string, text: string): Promise<Comment> {
return fetcher.put(`/comment/${id}`, { url }, { text })
}
/**
* Remove comment on a page
*/
async function removeComment(url: string, id: string): Promise<void> {
return fetcher.put(`/comment/${id}`, { url }, { delete: true })
}
/**
* Vote for a comment
*/
async function vote(url: string, id: string, vote: Vote): Promise<{ id: string; vote: number }> {
return fetcher.put<{ id: string; vote: number }>(`/vote/${id}`, { url, vote })
}
return {
getConfig,
getUser,
getComments,
addComment,
updateComment,
removeComment,
vote,
}
}
-11
View File
@@ -1,11 +0,0 @@
/** Base path to API */
export const API_BASE = '/api/v1'
/** Header name for JWT token */
export const JWT_HEADER = 'X-JWT'
/** Header name for XSRF token */
export const XSRF_HEADER = 'X-XSRF-TOKEN'
/** Cookie field with XSRF token */
export const XSRF_COOKIE = 'XSRF-TOKEN'
-4
View File
@@ -1,4 +0,0 @@
export * from './clients'
export * from './clients/admin'
export * from './clients/auth'
export * from './clients/public'
-11
View File
@@ -1,11 +0,0 @@
export function getCookie(name: string): string | undefined {
const matches = document.cookie.match(
new RegExp(`(?:^|; )${name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1')}=([^;]*)`)
)
if (matches === null) {
return
}
return decodeURIComponent(matches[1])
}
-94
View File
@@ -1,94 +0,0 @@
import { JWT_HEADER, XSRF_COOKIE, XSRF_HEADER } from '../consts'
import { getCookie } from '../lib/cookies'
export type QueryParams = Record<string, string | number | string[] | number[] | undefined>
export type Payload = BodyInit | Record<string, unknown> | null
export type BodylessMethod = <T>(url: string, query?: QueryParams) => Promise<T>
export type BodyMethod = <T>(url: string, query?: QueryParams, body?: Payload) => Promise<T>
export interface Client {
get: BodylessMethod
put: BodyMethod
post: BodyMethod
delete: BodylessMethod
}
/** JWT token received from server and will be send by each request, if it present */
let token: string | undefined
export const createFetcher = (site: string, baseUrl: string): Client => {
const client = {
get: <T>(uri: string, query?: QueryParams): Promise<T> => request<T>('get', uri, query),
put: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
request<T>('put', uri, query, body),
post: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
request<T>('post', uri, query, body),
delete: <T>(uri: string, query?: QueryParams, body?: Payload): Promise<T> =>
request<T>('delete', uri, query, body),
}
/**
* Fetcher is abstraction on top of fetch
*
* @method - a string to set http method
* @uri uri to API endpoint
* @query - collection of query params. They will be concatenated to URL. `siteId` will be added automatically.
* @body - data for sending to the server. If you pass object it will be stringified. If you pass form data it will be sent as is. Content type headers will be added automatically.
*/
async function request<T>(
method: string,
uri: string,
query: QueryParams = {},
body?: Payload
): Promise<T> {
const searchParams = new URLSearchParams({ site, ...query })
searchParams.sort()
const url = `${baseUrl}${uri}?${searchParams.toString()}`
const headers = new Headers()
const params: RequestInit = { method, headers }
// Save token in memory and pass it into headers in case if storing cookies is disabled
if (token) {
headers.set(JWT_HEADER, token)
}
// An HTTP header cannot be empty.
// Although some webservers allow this (nginx, Apache), others answer 400 Bad Request (lighttpd).
const xsrfToken = getCookie(XSRF_COOKIE)
if (typeof xsrfToken === 'string') {
headers.set(XSRF_HEADER, xsrfToken)
}
if (typeof body === 'object' && body !== null && !(body instanceof FormData)) {
headers.set('Content-Type', 'application/json')
params.body = JSON.stringify(body)
} else {
params.body = body
}
return fetch(url, params).then<T>((res) => {
if ([401, 403].includes(res.status)) {
token = undefined
return Promise.reject('Unauthorized')
}
token = res.headers.get(JWT_HEADER) ?? token
return res
.text()
.catch(Object)
.then((data: string) => {
if (res.status < 200 || res.status > 299) {
return Promise.reject(data)
}
try {
return JSON.parse(data) as T
} catch (e) {
return data as unknown as T
}
})
})
}
return client
}
-57
View File
@@ -1,57 +0,0 @@
{
"name": "@remark42/api",
"version": "0.6.0-alpha.11",
"description": "Implementation of API methods for Remark42",
"repository": {
"type": "git",
"url": "git+https://github.com/umputun/remark42.git#master"
},
"bugs": {
"url": "https://github.com/umputun/remark42/issues"
},
"homepage": "https://github.com/umputun/remark42/tree/master/frontend/packages/api#readme",
"keywords": [
"remark42",
"comments"
],
"author": "Paul Mineev",
"license": "MIT",
"scripts": {
"dev": "tsc -w --incremental",
"build:cjs": "tsc --module CommonJS",
"build:esm": "tsc --module ESNext",
"build": "npm-run-all cleanup --parallel build:*",
"postpublish": "pnpm run cleanup",
"cleanup": "rm -rf *.js *.d.ts **/*.js **/*.d.ts *.tsbuildinfo",
"test": "vitest",
"coverage": "vitest run --coverage",
"lint": "eslint --ext .cjs,.mjs,.ts --max-warnings=0",
"type-check": "tsc --noEmit",
"lint-staged:lint": "eslint --fix --ext .cjs,.mjs,.ts"
},
"packageManager": "pnpm@10.10.0",
"devDependencies": {
"@types/node": "^26.0.1",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"jsdom": "^29.1.1",
"msw": "^2.14.6",
"npm-run-all": "^4.1.5",
"prettier": "^3.9.4",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vitest": "^4.1.9"
},
"type": "module",
"files": [
"./*.js",
"./*.d.ts",
"./clients/*.d.ts",
"./lib/*.d.ts"
]
}
@@ -1,105 +0,0 @@
import { beforeEach, describe, expect } from 'vitest'
import { mockEndpoint } from '../test-utils'
import { BlockTTL, createAdminClient } from '../../clients/admin'
interface Context {
client: ReturnType<typeof createAdminClient>
}
describe<Context>('Admin Client', (adminClient) => {
beforeEach<Context>((ctx) => {
ctx.client = createAdminClient({ siteId: 'mysite', baseUrl: 'http://localhost/remark42' })
})
adminClient('should return list of blocked users', async ({ client }) => {
const data = [{ id: 1 }, { id: 2 }]
mockEndpoint('/remark42/api/v1/blocked', { body: data })
await expect(client.getBlockedUsers()).resolves.toEqual(data)
})
const ttlCases: [BlockTTL, string][] = [
['permanently', '0'],
['1440m', '1440m'],
['43200m', '43200m'],
]
ttlCases.forEach(([ttl, expected]) => {
adminClient(`should block user with ttl: ${ttl}`, async ({ client }) => {
const data = { block: true, site_id: 'remark42', user_id: '1' }
const ref = mockEndpoint('/remark42/api/v1/user/1', { method: 'put', body: data })
await expect(client.blockUser('1', ttl)).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('ttl')).toBe(expected)
})
})
adminClient('should unblock user', async ({ client }) => {
const data = { block: false, site_id: 'remark42', user_id: '1' }
const ref = mockEndpoint('/remark42/api/v1/user/1', { method: 'put', body: data })
await expect(client.unblockUser('1')).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('block')).toBe('0')
})
adminClient('should mark user as verified', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/verify/1', { method: 'put' })
await client.verifyUser('1')
expect(ref.req.url.searchParams.get('verified')).toBe('1')
})
adminClient('should mark user as unverified', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/verify/1', { method: 'put' })
await client.unverifyUser('1')
expect(ref.req.url.searchParams.get('verified')).toBe('0')
})
adminClient('should approve removing request', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/deleteme')
await client.approveRemovingRequest('token')
expect(ref.req.url.searchParams.get('token')).toBe('token')
})
adminClient('should pin comment', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/pin/1', { method: 'put' })
await client.pinComment('1')
expect(ref.req.url.searchParams.get('pinned')).toBe('1')
})
adminClient('should unpin comment', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/pin/1', { method: 'put' })
await client.unpinComment('1')
expect(ref.req.url.searchParams.get('pinned')).toBe('0')
})
adminClient('should remove comment', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'delete' })
const url = '/post/1'
await client.removeComment(url, '1')
expect(ref.req.url.searchParams.get('url')).toBe(url)
})
adminClient('should enable commenting on a page', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/readonly', { method: 'put' })
const url = '/post/1'
await client.enableCommenting(url)
expect(ref.req.url.searchParams.get('ro')).toBe('1')
expect(ref.req.url.searchParams.get('url')).toBe(url)
})
adminClient('should disable commenting on a page', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/readonly', { method: 'put' })
const url = '/post/1'
await client.disableCommenting('/post/1')
expect(ref.req.url.searchParams.get('ro')).toBe('0')
expect(ref.req.url.searchParams.get('url')).toBe(url)
})
})
@@ -1,59 +0,0 @@
import { beforeEach, describe, expect } from 'vitest'
import { mockEndpoint } from '../test-utils'
import { createAuthClient } from '../../clients/auth'
interface Content {
client: ReturnType<typeof createAuthClient>
}
describe<Content>('Auth Client', (authClient) => {
beforeEach<Content>((ctx) => {
ctx.client = createAuthClient({ siteId: 'mysite', baseUrl: 'http://localhost/remark42' })
})
authClient('should authorize as anonymouse', async ({ client }) => {
const data = { id: 1 }
const ref = mockEndpoint('/remark42/auth/anonymous/login', { body: data })
await expect(client.anonymous('username')).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('aud')).toBe('mysite')
expect(ref.req.url.searchParams.get('user')).toBe('username')
})
authClient('should authorize with email', async ({ client }) => {
const data = { id: 1 }
const ref = mockEndpoint('/remark42/auth/email/login', { body: data })
const tokenVerification = await client.email('username@example.com', 'username')
expect(ref.req.url.searchParams.get('address')).toBe('username@example.com')
expect(ref.req.url.searchParams.get('user')).toBe('username')
await expect(tokenVerification('token')).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('token')).toBe('token')
})
authClient('should authorize with telegram', async ({ client }) => {
const data = {
bot: 'remark42bot',
token: 'token',
}
const user = { id: 1 }
mockEndpoint('/remark42/auth/telegram/login', { body: data })
const telegramAuth = await client.telegram()
expect(telegramAuth.bot).toBe(data.bot)
expect(telegramAuth.token).toBe(data.token)
const ref = mockEndpoint('/remark42/auth/telegram/login', { body: user })
await expect(telegramAuth.verify()).resolves.toEqual(user)
expect(ref.req.url.searchParams.get('token')).toBe('token')
})
authClient('should logout', async ({ client }) => {
mockEndpoint('/remark42/auth/logout')
await expect(client.logout()).resolves.toBe('')
})
})
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createClient } from '../..'
describe('Client', () => {
it('should create a client', () => {
const params = { siteId: 'mysite', baseUrl: 'http://localhost/remark42' }
const client = createClient(params)
expect(client).toBeDefined()
expect(client.admin).toBeDefined()
expect(client.auth).toBeDefined()
expect(client.public).toBeDefined()
})
})
@@ -1,116 +0,0 @@
import { beforeEach, describe, expect } from 'vitest'
import { mockEndpoint } from '../test-utils'
import { createPublicClient, GetUserCommentsParams, Vote } from '../../clients/public'
interface Context {
client: ReturnType<typeof createPublicClient>
}
describe<Context>('Public Client', (publicClient) => {
beforeEach<Context>((ctx) => {
ctx.client = createPublicClient({ siteId: 'mysite', baseUrl: 'http://localhost/remark42' })
})
publicClient('getConfig: should return config', async ({ client }) => {
const data = { x: 1, y: 2 }
mockEndpoint('/remark42/api/v1/config', { body: data })
await expect(client.getConfig()).resolves.toEqual(data)
})
publicClient('getComments: should return page comments', async ({ client }) => {
const data = { post: { id: '1' }, node: [{ id: 1 }] }
const ref = mockEndpoint('/remark42/api/v1/comments', { body: data })
await expect(client.getComments('/post/1')).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('url')).toBe('/post/1')
})
const commentRequestCases: GetUserCommentsParams[] = [
{ url: '' },
{ url: '' },
{ url: '', limit: 10 },
{ url: '', skip: 10 },
{ url: '', skip: 10, limit: 0 },
]
commentRequestCases.forEach((params) => {
publicClient(
`getComments: should return user comments with params: ${JSON.stringify(params)}`,
async ({ client }) => {
const data = [{ id: 1 }, { id: 2 }]
const ref = mockEndpoint('/remark42/api/v1/find', { body: data })
await expect(client.getComments(params)).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('limit')).toBe(
params.limit === undefined ? null : `${params.limit}`
)
expect(ref.req.url.searchParams.get('skip')).toBe(
params.skip === undefined ? null : `${params.skip}`
)
}
)
})
publicClient('addComment: should add comment', async ({ client }) => {
const data = { id: '1', text: 'test' }
const ref = mockEndpoint('/remark42/api/v1/comment', { method: 'post', body: data })
await expect(client.addComment('/post/my-first-post', { text: 'test' })).resolves.toEqual(data)
await expect(ref.req.json()).resolves.toEqual({
text: data.text,
locator: {
site: 'mysite',
url: '/post/my-first-post',
},
})
})
publicClient('updateComment: should update comment', async ({ client }) => {
const data = { id: 1, body: 'test' }
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'put', body: data })
await expect(client.updateComment('/post/my-first-post', '1', 'test')).resolves.toEqual(data)
await expect(ref.req.json()).resolves.toEqual({ text: 'test' })
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
})
publicClient('should remove comment', async ({ client }) => {
const ref = mockEndpoint('/remark42/api/v1/comment/1', { method: 'put' })
await expect(client.removeComment('/post/my-first-post', '1')).resolves.toBe('')
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
})
const voteRequestCases: { vote: Vote; value: string }[] = [
{ vote: 1, value: 'upvote' },
{ vote: -1, value: 'downvote' },
]
voteRequestCases.forEach(({ vote, value }) => {
publicClient(`vote: should ${value} for comment`, async ({ client }) => {
const data = { id: 1, vote: 2 }
const ref = mockEndpoint('/remark42/api/v1/vote/1', { method: 'put', body: data })
await expect(client.vote('/post/my-first-post', '1', vote)).resolves.toEqual(data)
expect(ref.req.url.searchParams.get('url')).toBe('/post/my-first-post')
expect(ref.req.url.searchParams.get('vote')).toBe(`${vote}`)
})
})
publicClient('getUser: should return user when logged in', async ({ client }) => {
const user = { id: '1', username: 'user' }
mockEndpoint('/remark42/auth/status', { body: { status: 'logged in', user: 'user' } })
mockEndpoint('/remark42/api/v1/user', { body: user })
await expect(client.getUser()).resolves.toEqual(user)
})
publicClient('getUser: should return null when not logged in', async ({ client }) => {
mockEndpoint('/remark42/auth/status', { body: { status: 'not logged in' } })
await expect(client.getUser()).resolves.toBeNull()
})
publicClient('getUser: should return null when status probe fails', async ({ client }) => {
mockEndpoint('/remark42/auth/status', { status: 500, body: 'boom' })
await expect(client.getUser()).resolves.toBeNull()
})
})
@@ -1,6 +0,0 @@
import { test, expect } from 'vitest'
import { createClient } from '..'
test('create client', () => {
expect(() => createClient({ siteId: 'site', baseUrl: '' })).not.toThrow()
})
@@ -1,117 +0,0 @@
import { beforeEach, describe, expect } from 'vitest'
import { mockEndpoint } from '../test-utils'
import { JWT_HEADER, XSRF_COOKIE, XSRF_HEADER } from '../../consts'
import { Client, createFetcher } from '../../lib/fetcher'
interface Context {
client: Client
}
describe<Context>('Fetcher', (fetcher) => {
beforeEach<Context>((ctx) => {
ctx.client = createFetcher('remark42', 'http://localhost')
})
fetcher('get', async ({ client }) => {
const ref = mockEndpoint('/test')
await client.get('/test')
expect(ref.req.method).toBe('GET')
})
fetcher('post', async ({ client }) => {
const ref = mockEndpoint('/test', { method: 'post' })
await client.post('/test')
expect(ref.req.method).toBe('POST')
})
fetcher('put', async ({ client }) => {
const ref = mockEndpoint('/test', { method: 'put' })
await client.put('/test')
expect(ref.req.method).toBe('PUT')
})
fetcher('delete', async ({ client }) => {
const ref = mockEndpoint('/test', { method: 'delete' })
await client.delete('/test')
expect(ref.req.method).toBe('DELETE')
})
fetcher('should send json', async ({ client }) => {
const data = { name: 'test' }
const ref = mockEndpoint('/test', { method: 'post', body: data })
await expect(client.post('/test', {}, data)).resolves.toStrictEqual(data)
await expect(ref.req.json()).resolves.toStrictEqual(data)
expect(ref.req.headers.get('Content-Type')).toBe('application/json')
})
fetcher('should send text', async ({ client }) => {
const data = 'text'
const ref = mockEndpoint('/test', { method: 'post', body: data })
await expect(client.post('/test', {}, data)).resolves.toBe(data)
await expect(ref.req.text()).resolves.toBe(data)
expect(ref.req.headers.get('Content-Type')).toMatch('text/plain')
})
fetcher('should send query', async ({ client }) => {
const ref = mockEndpoint('/test')
await expect(client.get('/test', { x: 1, p: 2 })).resolves.toBe('')
expect(ref.req.url.searchParams.get('x')).toBe('1')
expect(ref.req.url.searchParams.get('p')).toBe('2')
})
fetcher('should sort query params', async ({ client }) => {
const ref = mockEndpoint('/test')
await expect(client.get('/test', { x: 1, p: 2 })).resolves.toBe('')
expect(ref.req.url.search).toBe('?p=2&site=remark42&x=1')
})
fetcher(
'should set active token and then clean it on unauthorized response',
async ({ client }) => {
let ref = mockEndpoint('/user', { headers: { [JWT_HEADER]: 'token' } })
// token should be saved
await client.get('/user')
// the first call should be without token
expect(ref.req.headers.get(JWT_HEADER)).toBe(null)
// the second call should be with token
await client.get('/user')
// check if the second call was with token
expect(ref.req.headers.get(JWT_HEADER)).toBe('token')
// unauthorized response should clean token
ref = mockEndpoint('/user', { status: 401 })
// the third call should be with token but token should be cleaned after it
await expect(client.get('/user')).rejects.toBe('Unauthorized')
// the fourth call should be without token
await expect(client.get('/user')).rejects.toBe('Unauthorized')
// check if the fourth call was with token
expect(ref.req.headers.get(JWT_HEADER)).toBe(null)
}
)
fetcher('should add XSRF header if we have it in cookies', async ({ client }) => {
const ref = mockEndpoint('/user')
Object.defineProperty(document, 'cookie', {
writable: true,
value: `${XSRF_COOKIE}=token`,
})
await client.get('/user')
expect(ref.req.headers.get(XSRF_HEADER)).toBe('token')
})
fetcher('should throw error on api response with status code 400', async ({ client }) => {
mockEndpoint('/user', { status: 400 })
await expect(client.get('/user')).rejects.toBe('')
})
})
-14
View File
@@ -1,14 +0,0 @@
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './test-utils'
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterEach(() => {
server.resetHandlers()
})
afterAll(() => {
server.close()
})
-59
View File
@@ -1,59 +0,0 @@
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
export const server = setupServer()
interface CapturedRequest {
url: URL
method: string
headers: Headers
json: () => Promise<unknown>
text: () => Promise<string>
}
interface RequestRef {
req: CapturedRequest
}
export function mockEndpoint(
url: string,
params: {
method?: 'get' | 'put' | 'post' | 'delete'
body?: number | string | null | Record<string, unknown> | unknown[]
status?: number
headers?: Record<string, string | string[]>
} = {}
): RequestRef {
const { body, method = 'get', status = 200, headers } = params
const result = { req: {} } as RequestRef
server.use(
http[method](url, ({ request }) => {
const captured = request.clone()
result.req = {
url: new URL(request.url),
method: request.method,
headers: request.headers,
json: () => captured.clone().json(),
text: () => captured.clone().text(),
}
const responseHeaders = new Headers()
if (headers) {
for (const [key, value] of Object.entries(headers)) {
if (Array.isArray(value)) {
for (const v of value) responseHeaders.append(key, v)
} else {
responseHeaders.set(key, value)
}
}
}
return body === undefined
? new HttpResponse(null, { status, headers: responseHeaders })
: HttpResponse.json(body, { status, headers: responseHeaders })
})
)
return result
}
@@ -1,5 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["**/*.ts"],
"exclude": [".turbo", "node_modules"]
}
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"lib": ["DOM"],
"moduleResolution": "node",
"baseUrl": "./",
"declaration": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["index.ts", "clients", "lib"],
"exclude": ["tests", "coverage"]
}
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
environmentOptions: {
jsdom: {
url: 'http://localhost',
},
},
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.ts'],
},
})
-1518
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,4 +1,3 @@
packages:
- "apps/*"
- "e2e"
- "packages/*"