@palbase/backend 20.0.0 → 21.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +10 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -42
- package/dist/index.d.ts +1 -42
- package/dist/index.js +8 -39
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +23 -2
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.js +23 -2
- package/dist/openapi/index.js.map +1 -1
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +3 -3
- package/dist/test/index.d.ts +3 -3
- package/dist/test/index.js.map +1 -1
- package/package.json +2 -2
- package/template/package.json +2 -2
package/dist/test/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/test/index.ts","../../src/test/api.ts"],"sourcesContent":["export { api, createTestApi, TestApiError } from \"./api.js\";\nexport type {\n CallOptions,\n ErrorEnvelope,\n RecordedRequest,\n TestApi,\n TestApiConfig,\n TestIdentity,\n} from \"./api.js\";\n","/** The client a tenant's tests use to call their own backend.\n *\n * These tests run against a REAL deployment — the release the deploy just built,\n * serving from the same Environment as production, with the same database, the\n * same secrets and the same gateway in front of it. So this client is a plain\n * HTTP client, not a simulation: every call crosses Kong, the API key check, the\n * auth rail, the zod validation at the boundary, and row-level security, exactly\n * as a shipped app's call does.\n *\n * There is deliberately no schema knowledge here. The tenant already wrote their\n * types — `import type { TodoSchema } from \"../models/todos/shared.js\"` — so a\n * test types its own call (`api.get<TodoSchema[]>(\"/todos\")`) and can validate it\n * with the same zod schema the endpoint declares. A second generated client would\n * be a second thing to keep in step.\n */\n\n/** How to reach the release under test. Supplied by the deploy, never guessed. */\nexport interface TestApiConfig {\n baseUrl: string;\n apiKey: string;\n /** This deploy's secret. Without it the request is served the LIVE release. */\n candidateToken: string;\n /** The run's minted logins, keyed by the name declared in config/test-users.ts. */\n identities?: Record<string, TestIdentity>;\n}\n\n/** One login the deploy minted for this run.\n *\n * `accessToken` is the session issued when the identity was created, and is what\n * `signInAs` uses. The credentials come along for a test that wants to exercise\n * the login rail itself — but a suite that switches users repeatedly must not be\n * signing in each time: those calls come from one address and trip the login rate\n * limiter, failing tests for a reason that has nothing to do with the code\n * under test.\n */\nexport interface TestIdentity {\n id?: string;\n email: string;\n password: string;\n accessToken?: string;\n}\n\n/** One call the suite made, in the order it was made. */\nexport interface RecordedRequest {\n method: string;\n path: string;\n status: number;\n ms: number;\n}\n\n/** A non-2xx answer, carrying the platform's error envelope.\n *\n * The envelope is the contract every Palbase endpoint answers with, so a test\n * asserts on `status`/`error`/`data` rather than parsing a message. The message\n * exists for the human reading a failed deploy.\n */\nexport class TestApiError extends Error {\n readonly status: number;\n readonly error: string;\n /** Payload of an error your code threw — `new BadRequest({ fields })` arrives here. */\n readonly data: unknown;\n /** The whole envelope, exactly as the server sent it. */\n readonly body: ErrorEnvelope;\n\n constructor(method: string, path: string, status: number, body: unknown) {\n const envelope = (body ?? {}) as ErrorEnvelope;\n const code = envelope.error ?? String(status);\n super(`${method} ${path} → ${status} ${code}${envelope.error_description ? `: ${envelope.error_description}` : \"\"}`);\n this.name = \"TestApiError\";\n this.status = status;\n this.error = code;\n this.data = envelope.data;\n this.body = envelope;\n }\n}\n\n/** A Palbase error response.\n *\n * `data` carries the payload of an error your code threw. Validation refused at\n * the boundary — before your handler runs — answers with `details` instead, one\n * entry per field. The index signature is deliberate: whatever the server sends\n * is readable from a test, so no assertion is ever blocked on this type being\n * exhaustive.\n */\nexport interface ErrorEnvelope {\n error?: string;\n error_description?: string;\n status?: number;\n request_id?: string;\n data?: unknown;\n details?: Array<{ field: string; message: string }>;\n [key: string]: unknown;\n}\n\nexport interface CallOptions {\n headers?: Record<string, string>;\n}\n\nexport interface TestApi {\n get<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n post<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n patch<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n put<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n delete<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n /** HTTP QUERY (RFC 10008): a safe, idempotent read whose filter travels in the body. */\n query<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n /** Sign in with credentials you supply. */\n signIn(credentials: { email: string; password: string }): Promise<{ id: string; email?: string }>;\n /**\n * Sign in as one of the identities this run was given, by the name you\n * declared it under in `config/test-users.ts`.\n *\n * Each is minted by the platform for the length of ONE deploy, seeded with the\n * data that declaration describes, and retired after — which is why this works\n * on every Environment including production, where a committed fixture password\n * is refused on purpose.\n */\n signInAs(name: string): Promise<{ id: string; email?: string }>;\n signOut(): Promise<void>;\n /** Drop the bearer without calling the server — the anonymous caller. */\n asAnonymous(): void;\n /** Every call made, in order. Printed for the failing test in a red deploy. */\n readonly requests: readonly RecordedRequest[];\n}\n\nfunction required(value: string, envName: string): string {\n if (!value) {\n throw new Error(\n `${envName} is not set — the test client has nowhere to send requests. ` +\n `This is set by the deploy that runs your tests; if you are running them by hand, set it yourself.`,\n );\n }\n return value;\n}\n\nexport function createTestApi(config: TestApiConfig): TestApi {\n const baseUrl = required(config.baseUrl, \"PALBASE_TEST_BASE_URL\").replace(/\\/$/, \"\");\n const apiKey = required(config.apiKey, \"PALBASE_TEST_API_KEY\");\n const candidateToken = required(config.candidateToken, \"PALBASE_TEST_CANDIDATE\");\n\n const requests: RecordedRequest[] = [];\n let bearer: string | null = null;\n\n async function call<T>(method: string, path: string, body: unknown, opts: CallOptions = {}): Promise<T> {\n const headers: Record<string, string> = {\n apikey: apiKey,\n // Selects the release under test. Omit it and the gateway serves the LIVE\n // one, which would make the whole suite grade the wrong code.\n \"x-palbase-candidate\": candidateToken,\n ...opts.headers,\n };\n if (bearer) headers.authorization = `Bearer ${bearer}`;\n if (body !== undefined) headers[\"content-type\"] = \"application/json\";\n\n const startedAt = Date.now();\n const res = await fetch(`${baseUrl}${path}`, {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n const text = await res.text();\n const parsed: unknown = text ? safeParse(text) : undefined;\n\n requests.push({ method, path, status: res.status, ms: Date.now() - startedAt });\n\n if (!res.ok) throw new TestApiError(method, path, res.status, parsed);\n return parsed as T;\n }\n\n return {\n requests,\n get: (path, opts) => call(\"GET\", path, undefined, opts),\n post: (path, body, opts) => call(\"POST\", path, body, opts),\n patch: (path, body, opts) => call(\"PATCH\", path, body, opts),\n put: (path, body, opts) => call(\"PUT\", path, body, opts),\n delete: (path, opts) => call(\"DELETE\", path, undefined, opts),\n query: (path, body, opts) => call(\"QUERY\", path, body, opts),\n\n async signInAs(name) {\n const identity = (config.identities ?? {})[name];\n if (!identity) {\n const declared = Object.keys(config.identities ?? {});\n throw new Error(\n `no test identity named ${JSON.stringify(name)} — the deploy mints one per user declared in ` +\n `config/test-users.ts` +\n (declared.length\n ? `; this run has: ${declared.join(\", \")}`\n : // Not \"you declared none\": from here the two causes are\n // indistinguishable, and blaming the customer's config for a\n // platform failure sends them to look in the wrong file. The\n // deploy log names which one it was.\n \", and this run has none — either your config declares no users \" +\n \"or the deploy could not mint them; the deploy log says which\"),\n );\n }\n // The session the mint already issued — no network call, so switching\n // users is free and the login rail never sees this run.\n if (identity.accessToken) {\n bearer = identity.accessToken;\n return { id: identity.id ?? \"\", email: identity.email };\n }\n return this.signIn(identity);\n },\n\n async signIn(credentials) {\n const result = await call<{ access_token: string; user?: { id: string; email?: string } }>(\n \"POST\",\n \"/auth/login\",\n credentials,\n );\n bearer = result.access_token;\n return result.user ?? { id: \"\" };\n },\n async signOut() {\n await call(\"POST\", \"/auth/logout\", undefined);\n bearer = null;\n },\n asAnonymous() {\n bearer = null;\n },\n };\n}\n\n/** The run's identities, as the deploy passed them. Absent is not an error: a\n * project that declares none still runs every test that needs no login. */\nfunction parseIdentities(raw: string | undefined): Record<string, TestIdentity> {\n if (!raw) return {};\n try {\n return JSON.parse(raw) as Record<string, TestIdentity>;\n } catch {\n return {};\n }\n}\n\nfunction safeParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/** The client the deploy configured, from the environment it set.\n *\n * Constructed lazily so importing this module outside a test run — a typecheck,\n * an editor — does not fail on absent configuration.\n */\nlet configured: TestApi | null = null;\n\nexport const api: TestApi = new Proxy({} as TestApi, {\n get(_target, prop) {\n configured ??= createTestApi({\n baseUrl: process.env.PALBASE_TEST_BASE_URL ?? \"\",\n apiKey: process.env.PALBASE_TEST_API_KEY ?? \"\",\n candidateToken: process.env.PALBASE_TEST_CANDIDATE ?? \"\",\n identities: parseIdentities(process.env.PALBASE_TEST_IDENTITIES),\n });\n return Reflect.get(configured, prop, configured);\n },\n});\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/test/index.ts","../../src/test/api.ts"],"sourcesContent":["export { api, createTestApi, TestApiError } from \"./api.js\";\nexport type {\n CallOptions,\n ErrorEnvelope,\n RecordedRequest,\n TestApi,\n TestApiConfig,\n TestIdentity,\n} from \"./api.js\";\n","/** The client a tenant's tests use to call their own backend.\n *\n * These tests run against a REAL deployment — the release the deploy just built,\n * serving from the same Environment as production, with the same database, the\n * same secrets and the same gateway in front of it. So this client is a plain\n * HTTP client, not a simulation: every call crosses the gateway, the API key\n * check, the auth rail, the zod validation at the boundary, and row-level\n * security, exactly as a shipped app's call does.\n *\n * There is deliberately no schema knowledge here. The tenant already wrote their\n * types — `import type { TodoSchema } from \"../models/todos/shared.js\"` — so a\n * test types its own call (`api.get<TodoSchema[]>(\"/todos\")`) and can validate it\n * with the same zod schema the endpoint declares. A second generated client would\n * be a second thing to keep in step.\n */\n\n/** How to reach the release under test. Supplied by the deploy, never guessed. */\nexport interface TestApiConfig {\n baseUrl: string;\n apiKey: string;\n /** This deploy's secret. Without it the request is served the LIVE release. */\n candidateToken: string;\n /** The run's minted logins, keyed by the name declared in config/test-users.ts. */\n identities?: Record<string, TestIdentity>;\n}\n\n/** One login the deploy minted for this run.\n *\n * `accessToken` is the session issued when the identity was created, and is what\n * `signInAs` uses. The credentials come along for a test that wants to exercise\n * the login rail itself — but a suite that switches users repeatedly must not be\n * signing in each time: those calls come from one address and trip the login rate\n * limiter, failing tests for a reason that has nothing to do with the code\n * under test.\n */\nexport interface TestIdentity {\n id?: string;\n email: string;\n password: string;\n accessToken?: string;\n}\n\n/** One call the suite made, in the order it was made. */\nexport interface RecordedRequest {\n method: string;\n path: string;\n status: number;\n ms: number;\n}\n\n/** A non-2xx answer, carrying the platform's error envelope.\n *\n * The envelope is the contract every Palbase endpoint answers with, so a test\n * asserts on `status`/`error`/`data` rather than parsing a message. The message\n * exists for the human reading a failed deploy.\n */\nexport class TestApiError extends Error {\n readonly status: number;\n readonly error: string;\n /** Payload of an error your code threw — `new BadRequest({ fields })` arrives here. */\n readonly data: unknown;\n /** The whole envelope, exactly as the server sent it. */\n readonly body: ErrorEnvelope;\n\n constructor(method: string, path: string, status: number, body: unknown) {\n const envelope = (body ?? {}) as ErrorEnvelope;\n const code = envelope.error ?? String(status);\n super(`${method} ${path} → ${status} ${code}${envelope.error_description ? `: ${envelope.error_description}` : \"\"}`);\n this.name = \"TestApiError\";\n this.status = status;\n this.error = code;\n this.data = envelope.data;\n this.body = envelope;\n }\n}\n\n/** A Palbase error response.\n *\n * `data` carries the payload of an error your code threw. Validation refused at\n * the boundary — before your handler runs — answers with `details` instead, one\n * entry per field. The index signature is deliberate: whatever the server sends\n * is readable from a test, so no assertion is ever blocked on this type being\n * exhaustive.\n */\nexport interface ErrorEnvelope {\n error?: string;\n error_description?: string;\n status?: number;\n request_id?: string;\n data?: unknown;\n details?: Array<{ field: string; message: string }>;\n [key: string]: unknown;\n}\n\nexport interface CallOptions {\n headers?: Record<string, string>;\n}\n\nexport interface TestApi {\n get<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n post<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n patch<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n put<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n delete<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n /** HTTP QUERY (RFC 10008): a safe, idempotent read whose filter travels in the body. */\n query<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n /** Sign in with credentials you supply. */\n signIn(credentials: { email: string; password: string }): Promise<{ id: string; email?: string }>;\n /**\n * Sign in as one of the identities this run was given, by the name you\n * declared it under in `config/test-users.ts`.\n *\n * Each is minted by the platform for the length of ONE deploy, seeded with the\n * data that declaration describes, and retired after — which is why this works\n * on every Environment including production, where a committed fixture password\n * is refused on purpose.\n */\n signInAs(name: string): Promise<{ id: string; email?: string }>;\n signOut(): Promise<void>;\n /** Drop the bearer without calling the server — the anonymous caller. */\n asAnonymous(): void;\n /** Every call made, in order. Printed for the failing test in a red deploy. */\n readonly requests: readonly RecordedRequest[];\n}\n\nfunction required(value: string, envName: string): string {\n if (!value) {\n throw new Error(\n `${envName} is not set — the test client has nowhere to send requests. ` +\n `This is set by the deploy that runs your tests; if you are running them by hand, set it yourself.`,\n );\n }\n return value;\n}\n\nexport function createTestApi(config: TestApiConfig): TestApi {\n const baseUrl = required(config.baseUrl, \"PALBASE_TEST_BASE_URL\").replace(/\\/$/, \"\");\n const apiKey = required(config.apiKey, \"PALBASE_TEST_API_KEY\");\n const candidateToken = required(config.candidateToken, \"PALBASE_TEST_CANDIDATE\");\n\n const requests: RecordedRequest[] = [];\n let bearer: string | null = null;\n\n async function call<T>(method: string, path: string, body: unknown, opts: CallOptions = {}): Promise<T> {\n const headers: Record<string, string> = {\n apikey: apiKey,\n // Selects the release under test. Omit it and the gateway serves the LIVE\n // one, which would make the whole suite grade the wrong code.\n \"x-palbase-candidate\": candidateToken,\n ...opts.headers,\n };\n if (bearer) headers.authorization = `Bearer ${bearer}`;\n if (body !== undefined) headers[\"content-type\"] = \"application/json\";\n\n const startedAt = Date.now();\n const res = await fetch(`${baseUrl}${path}`, {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n const text = await res.text();\n const parsed: unknown = text ? safeParse(text) : undefined;\n\n requests.push({ method, path, status: res.status, ms: Date.now() - startedAt });\n\n if (!res.ok) throw new TestApiError(method, path, res.status, parsed);\n return parsed as T;\n }\n\n return {\n requests,\n get: (path, opts) => call(\"GET\", path, undefined, opts),\n post: (path, body, opts) => call(\"POST\", path, body, opts),\n patch: (path, body, opts) => call(\"PATCH\", path, body, opts),\n put: (path, body, opts) => call(\"PUT\", path, body, opts),\n delete: (path, opts) => call(\"DELETE\", path, undefined, opts),\n query: (path, body, opts) => call(\"QUERY\", path, body, opts),\n\n async signInAs(name) {\n const identity = (config.identities ?? {})[name];\n if (!identity) {\n const declared = Object.keys(config.identities ?? {});\n throw new Error(\n `no test identity named ${JSON.stringify(name)} — the deploy mints one per user declared in ` +\n `config/test-users.ts` +\n (declared.length\n ? `; this run has: ${declared.join(\", \")}`\n : // Not \"you declared none\": from here the two causes are\n // indistinguishable, and blaming the customer's config for a\n // platform failure sends them to look in the wrong file. The\n // deploy log names which one it was.\n \", and this run has none — either your config declares no users \" +\n \"or the deploy could not mint them; the deploy log says which\"),\n );\n }\n // The session the mint already issued — no network call, so switching\n // users is free and the login rail never sees this run.\n if (identity.accessToken) {\n bearer = identity.accessToken;\n return { id: identity.id ?? \"\", email: identity.email };\n }\n return this.signIn(identity);\n },\n\n async signIn(credentials) {\n const result = await call<{ access_token: string; user?: { id: string; email?: string } }>(\n \"POST\",\n \"/auth/login\",\n credentials,\n );\n bearer = result.access_token;\n return result.user ?? { id: \"\" };\n },\n async signOut() {\n await call(\"POST\", \"/auth/logout\", undefined);\n bearer = null;\n },\n asAnonymous() {\n bearer = null;\n },\n };\n}\n\n/** The run's identities, as the deploy passed them. Absent is not an error: a\n * project that declares none still runs every test that needs no login. */\nfunction parseIdentities(raw: string | undefined): Record<string, TestIdentity> {\n if (!raw) return {};\n try {\n return JSON.parse(raw) as Record<string, TestIdentity>;\n } catch {\n return {};\n }\n}\n\nfunction safeParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/** The client the deploy configured, from the environment it set.\n *\n * Constructed lazily so importing this module outside a test run — a typecheck,\n * an editor — does not fail on absent configuration.\n */\nlet configured: TestApi | null = null;\n\nexport const api: TestApi = new Proxy({} as TestApi, {\n get(_target, prop) {\n configured ??= createTestApi({\n baseUrl: process.env.PALBASE_TEST_BASE_URL ?? \"\",\n apiKey: process.env.PALBASE_TEST_API_KEY ?? \"\",\n candidateToken: process.env.PALBASE_TEST_CANDIDATE ?? \"\",\n identities: parseIdentities(process.env.PALBASE_TEST_IDENTITIES),\n });\n return Reflect.get(configured, prop, configured);\n },\n});\n"],"mappings":"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","names":[]}
|
package/dist/test/index.d.cts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* These tests run against a REAL deployment — the release the deploy just built,
|
|
4
4
|
* serving from the same Environment as production, with the same database, the
|
|
5
5
|
* same secrets and the same gateway in front of it. So this client is a plain
|
|
6
|
-
* HTTP client, not a simulation: every call crosses
|
|
7
|
-
* auth rail, the zod validation at the boundary, and row-level
|
|
8
|
-
* as a shipped app's call does.
|
|
6
|
+
* HTTP client, not a simulation: every call crosses the gateway, the API key
|
|
7
|
+
* check, the auth rail, the zod validation at the boundary, and row-level
|
|
8
|
+
* security, exactly as a shipped app's call does.
|
|
9
9
|
*
|
|
10
10
|
* There is deliberately no schema knowledge here. The tenant already wrote their
|
|
11
11
|
* types — `import type { TodoSchema } from "../models/todos/shared.js"` — so a
|
package/dist/test/index.d.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* These tests run against a REAL deployment — the release the deploy just built,
|
|
4
4
|
* serving from the same Environment as production, with the same database, the
|
|
5
5
|
* same secrets and the same gateway in front of it. So this client is a plain
|
|
6
|
-
* HTTP client, not a simulation: every call crosses
|
|
7
|
-
* auth rail, the zod validation at the boundary, and row-level
|
|
8
|
-
* as a shipped app's call does.
|
|
6
|
+
* HTTP client, not a simulation: every call crosses the gateway, the API key
|
|
7
|
+
* check, the auth rail, the zod validation at the boundary, and row-level
|
|
8
|
+
* security, exactly as a shipped app's call does.
|
|
9
9
|
*
|
|
10
10
|
* There is deliberately no schema knowledge here. The tenant already wrote their
|
|
11
11
|
* types — `import type { TodoSchema } from "../models/todos/shared.js"` — so a
|
package/dist/test/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/test/api.ts"],"sourcesContent":["/** The client a tenant's tests use to call their own backend.\n *\n * These tests run against a REAL deployment — the release the deploy just built,\n * serving from the same Environment as production, with the same database, the\n * same secrets and the same gateway in front of it. So this client is a plain\n * HTTP client, not a simulation: every call crosses Kong, the API key check, the\n * auth rail, the zod validation at the boundary, and row-level security, exactly\n * as a shipped app's call does.\n *\n * There is deliberately no schema knowledge here. The tenant already wrote their\n * types — `import type { TodoSchema } from \"../models/todos/shared.js\"` — so a\n * test types its own call (`api.get<TodoSchema[]>(\"/todos\")`) and can validate it\n * with the same zod schema the endpoint declares. A second generated client would\n * be a second thing to keep in step.\n */\n\n/** How to reach the release under test. Supplied by the deploy, never guessed. */\nexport interface TestApiConfig {\n baseUrl: string;\n apiKey: string;\n /** This deploy's secret. Without it the request is served the LIVE release. */\n candidateToken: string;\n /** The run's minted logins, keyed by the name declared in config/test-users.ts. */\n identities?: Record<string, TestIdentity>;\n}\n\n/** One login the deploy minted for this run.\n *\n * `accessToken` is the session issued when the identity was created, and is what\n * `signInAs` uses. The credentials come along for a test that wants to exercise\n * the login rail itself — but a suite that switches users repeatedly must not be\n * signing in each time: those calls come from one address and trip the login rate\n * limiter, failing tests for a reason that has nothing to do with the code\n * under test.\n */\nexport interface TestIdentity {\n id?: string;\n email: string;\n password: string;\n accessToken?: string;\n}\n\n/** One call the suite made, in the order it was made. */\nexport interface RecordedRequest {\n method: string;\n path: string;\n status: number;\n ms: number;\n}\n\n/** A non-2xx answer, carrying the platform's error envelope.\n *\n * The envelope is the contract every Palbase endpoint answers with, so a test\n * asserts on `status`/`error`/`data` rather than parsing a message. The message\n * exists for the human reading a failed deploy.\n */\nexport class TestApiError extends Error {\n readonly status: number;\n readonly error: string;\n /** Payload of an error your code threw — `new BadRequest({ fields })` arrives here. */\n readonly data: unknown;\n /** The whole envelope, exactly as the server sent it. */\n readonly body: ErrorEnvelope;\n\n constructor(method: string, path: string, status: number, body: unknown) {\n const envelope = (body ?? {}) as ErrorEnvelope;\n const code = envelope.error ?? String(status);\n super(`${method} ${path} → ${status} ${code}${envelope.error_description ? `: ${envelope.error_description}` : \"\"}`);\n this.name = \"TestApiError\";\n this.status = status;\n this.error = code;\n this.data = envelope.data;\n this.body = envelope;\n }\n}\n\n/** A Palbase error response.\n *\n * `data` carries the payload of an error your code threw. Validation refused at\n * the boundary — before your handler runs — answers with `details` instead, one\n * entry per field. The index signature is deliberate: whatever the server sends\n * is readable from a test, so no assertion is ever blocked on this type being\n * exhaustive.\n */\nexport interface ErrorEnvelope {\n error?: string;\n error_description?: string;\n status?: number;\n request_id?: string;\n data?: unknown;\n details?: Array<{ field: string; message: string }>;\n [key: string]: unknown;\n}\n\nexport interface CallOptions {\n headers?: Record<string, string>;\n}\n\nexport interface TestApi {\n get<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n post<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n patch<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n put<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n delete<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n /** HTTP QUERY (RFC 10008): a safe, idempotent read whose filter travels in the body. */\n query<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n /** Sign in with credentials you supply. */\n signIn(credentials: { email: string; password: string }): Promise<{ id: string; email?: string }>;\n /**\n * Sign in as one of the identities this run was given, by the name you\n * declared it under in `config/test-users.ts`.\n *\n * Each is minted by the platform for the length of ONE deploy, seeded with the\n * data that declaration describes, and retired after — which is why this works\n * on every Environment including production, where a committed fixture password\n * is refused on purpose.\n */\n signInAs(name: string): Promise<{ id: string; email?: string }>;\n signOut(): Promise<void>;\n /** Drop the bearer without calling the server — the anonymous caller. */\n asAnonymous(): void;\n /** Every call made, in order. Printed for the failing test in a red deploy. */\n readonly requests: readonly RecordedRequest[];\n}\n\nfunction required(value: string, envName: string): string {\n if (!value) {\n throw new Error(\n `${envName} is not set — the test client has nowhere to send requests. ` +\n `This is set by the deploy that runs your tests; if you are running them by hand, set it yourself.`,\n );\n }\n return value;\n}\n\nexport function createTestApi(config: TestApiConfig): TestApi {\n const baseUrl = required(config.baseUrl, \"PALBASE_TEST_BASE_URL\").replace(/\\/$/, \"\");\n const apiKey = required(config.apiKey, \"PALBASE_TEST_API_KEY\");\n const candidateToken = required(config.candidateToken, \"PALBASE_TEST_CANDIDATE\");\n\n const requests: RecordedRequest[] = [];\n let bearer: string | null = null;\n\n async function call<T>(method: string, path: string, body: unknown, opts: CallOptions = {}): Promise<T> {\n const headers: Record<string, string> = {\n apikey: apiKey,\n // Selects the release under test. Omit it and the gateway serves the LIVE\n // one, which would make the whole suite grade the wrong code.\n \"x-palbase-candidate\": candidateToken,\n ...opts.headers,\n };\n if (bearer) headers.authorization = `Bearer ${bearer}`;\n if (body !== undefined) headers[\"content-type\"] = \"application/json\";\n\n const startedAt = Date.now();\n const res = await fetch(`${baseUrl}${path}`, {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n const text = await res.text();\n const parsed: unknown = text ? safeParse(text) : undefined;\n\n requests.push({ method, path, status: res.status, ms: Date.now() - startedAt });\n\n if (!res.ok) throw new TestApiError(method, path, res.status, parsed);\n return parsed as T;\n }\n\n return {\n requests,\n get: (path, opts) => call(\"GET\", path, undefined, opts),\n post: (path, body, opts) => call(\"POST\", path, body, opts),\n patch: (path, body, opts) => call(\"PATCH\", path, body, opts),\n put: (path, body, opts) => call(\"PUT\", path, body, opts),\n delete: (path, opts) => call(\"DELETE\", path, undefined, opts),\n query: (path, body, opts) => call(\"QUERY\", path, body, opts),\n\n async signInAs(name) {\n const identity = (config.identities ?? {})[name];\n if (!identity) {\n const declared = Object.keys(config.identities ?? {});\n throw new Error(\n `no test identity named ${JSON.stringify(name)} — the deploy mints one per user declared in ` +\n `config/test-users.ts` +\n (declared.length\n ? `; this run has: ${declared.join(\", \")}`\n : // Not \"you declared none\": from here the two causes are\n // indistinguishable, and blaming the customer's config for a\n // platform failure sends them to look in the wrong file. The\n // deploy log names which one it was.\n \", and this run has none — either your config declares no users \" +\n \"or the deploy could not mint them; the deploy log says which\"),\n );\n }\n // The session the mint already issued — no network call, so switching\n // users is free and the login rail never sees this run.\n if (identity.accessToken) {\n bearer = identity.accessToken;\n return { id: identity.id ?? \"\", email: identity.email };\n }\n return this.signIn(identity);\n },\n\n async signIn(credentials) {\n const result = await call<{ access_token: string; user?: { id: string; email?: string } }>(\n \"POST\",\n \"/auth/login\",\n credentials,\n );\n bearer = result.access_token;\n return result.user ?? { id: \"\" };\n },\n async signOut() {\n await call(\"POST\", \"/auth/logout\", undefined);\n bearer = null;\n },\n asAnonymous() {\n bearer = null;\n },\n };\n}\n\n/** The run's identities, as the deploy passed them. Absent is not an error: a\n * project that declares none still runs every test that needs no login. */\nfunction parseIdentities(raw: string | undefined): Record<string, TestIdentity> {\n if (!raw) return {};\n try {\n return JSON.parse(raw) as Record<string, TestIdentity>;\n } catch {\n return {};\n }\n}\n\nfunction safeParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/** The client the deploy configured, from the environment it set.\n *\n * Constructed lazily so importing this module outside a test run — a typecheck,\n * an editor — does not fail on absent configuration.\n */\nlet configured: TestApi | null = null;\n\nexport const api: TestApi = new Proxy({} as TestApi, {\n get(_target, prop) {\n configured ??= createTestApi({\n baseUrl: process.env.PALBASE_TEST_BASE_URL ?? \"\",\n apiKey: process.env.PALBASE_TEST_API_KEY ?? \"\",\n candidateToken: process.env.PALBASE_TEST_CANDIDATE ?? \"\",\n identities: parseIdentities(process.env.PALBASE_TEST_IDENTITIES),\n });\n return Reflect.get(configured, prop, configured);\n },\n});\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/test/api.ts"],"sourcesContent":["/** The client a tenant's tests use to call their own backend.\n *\n * These tests run against a REAL deployment — the release the deploy just built,\n * serving from the same Environment as production, with the same database, the\n * same secrets and the same gateway in front of it. So this client is a plain\n * HTTP client, not a simulation: every call crosses the gateway, the API key\n * check, the auth rail, the zod validation at the boundary, and row-level\n * security, exactly as a shipped app's call does.\n *\n * There is deliberately no schema knowledge here. The tenant already wrote their\n * types — `import type { TodoSchema } from \"../models/todos/shared.js\"` — so a\n * test types its own call (`api.get<TodoSchema[]>(\"/todos\")`) and can validate it\n * with the same zod schema the endpoint declares. A second generated client would\n * be a second thing to keep in step.\n */\n\n/** How to reach the release under test. Supplied by the deploy, never guessed. */\nexport interface TestApiConfig {\n baseUrl: string;\n apiKey: string;\n /** This deploy's secret. Without it the request is served the LIVE release. */\n candidateToken: string;\n /** The run's minted logins, keyed by the name declared in config/test-users.ts. */\n identities?: Record<string, TestIdentity>;\n}\n\n/** One login the deploy minted for this run.\n *\n * `accessToken` is the session issued when the identity was created, and is what\n * `signInAs` uses. The credentials come along for a test that wants to exercise\n * the login rail itself — but a suite that switches users repeatedly must not be\n * signing in each time: those calls come from one address and trip the login rate\n * limiter, failing tests for a reason that has nothing to do with the code\n * under test.\n */\nexport interface TestIdentity {\n id?: string;\n email: string;\n password: string;\n accessToken?: string;\n}\n\n/** One call the suite made, in the order it was made. */\nexport interface RecordedRequest {\n method: string;\n path: string;\n status: number;\n ms: number;\n}\n\n/** A non-2xx answer, carrying the platform's error envelope.\n *\n * The envelope is the contract every Palbase endpoint answers with, so a test\n * asserts on `status`/`error`/`data` rather than parsing a message. The message\n * exists for the human reading a failed deploy.\n */\nexport class TestApiError extends Error {\n readonly status: number;\n readonly error: string;\n /** Payload of an error your code threw — `new BadRequest({ fields })` arrives here. */\n readonly data: unknown;\n /** The whole envelope, exactly as the server sent it. */\n readonly body: ErrorEnvelope;\n\n constructor(method: string, path: string, status: number, body: unknown) {\n const envelope = (body ?? {}) as ErrorEnvelope;\n const code = envelope.error ?? String(status);\n super(`${method} ${path} → ${status} ${code}${envelope.error_description ? `: ${envelope.error_description}` : \"\"}`);\n this.name = \"TestApiError\";\n this.status = status;\n this.error = code;\n this.data = envelope.data;\n this.body = envelope;\n }\n}\n\n/** A Palbase error response.\n *\n * `data` carries the payload of an error your code threw. Validation refused at\n * the boundary — before your handler runs — answers with `details` instead, one\n * entry per field. The index signature is deliberate: whatever the server sends\n * is readable from a test, so no assertion is ever blocked on this type being\n * exhaustive.\n */\nexport interface ErrorEnvelope {\n error?: string;\n error_description?: string;\n status?: number;\n request_id?: string;\n data?: unknown;\n details?: Array<{ field: string; message: string }>;\n [key: string]: unknown;\n}\n\nexport interface CallOptions {\n headers?: Record<string, string>;\n}\n\nexport interface TestApi {\n get<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n post<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n patch<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n put<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n delete<T = unknown>(path: string, opts?: CallOptions): Promise<T>;\n /** HTTP QUERY (RFC 10008): a safe, idempotent read whose filter travels in the body. */\n query<T = unknown>(path: string, body?: unknown, opts?: CallOptions): Promise<T>;\n /** Sign in with credentials you supply. */\n signIn(credentials: { email: string; password: string }): Promise<{ id: string; email?: string }>;\n /**\n * Sign in as one of the identities this run was given, by the name you\n * declared it under in `config/test-users.ts`.\n *\n * Each is minted by the platform for the length of ONE deploy, seeded with the\n * data that declaration describes, and retired after — which is why this works\n * on every Environment including production, where a committed fixture password\n * is refused on purpose.\n */\n signInAs(name: string): Promise<{ id: string; email?: string }>;\n signOut(): Promise<void>;\n /** Drop the bearer without calling the server — the anonymous caller. */\n asAnonymous(): void;\n /** Every call made, in order. Printed for the failing test in a red deploy. */\n readonly requests: readonly RecordedRequest[];\n}\n\nfunction required(value: string, envName: string): string {\n if (!value) {\n throw new Error(\n `${envName} is not set — the test client has nowhere to send requests. ` +\n `This is set by the deploy that runs your tests; if you are running them by hand, set it yourself.`,\n );\n }\n return value;\n}\n\nexport function createTestApi(config: TestApiConfig): TestApi {\n const baseUrl = required(config.baseUrl, \"PALBASE_TEST_BASE_URL\").replace(/\\/$/, \"\");\n const apiKey = required(config.apiKey, \"PALBASE_TEST_API_KEY\");\n const candidateToken = required(config.candidateToken, \"PALBASE_TEST_CANDIDATE\");\n\n const requests: RecordedRequest[] = [];\n let bearer: string | null = null;\n\n async function call<T>(method: string, path: string, body: unknown, opts: CallOptions = {}): Promise<T> {\n const headers: Record<string, string> = {\n apikey: apiKey,\n // Selects the release under test. Omit it and the gateway serves the LIVE\n // one, which would make the whole suite grade the wrong code.\n \"x-palbase-candidate\": candidateToken,\n ...opts.headers,\n };\n if (bearer) headers.authorization = `Bearer ${bearer}`;\n if (body !== undefined) headers[\"content-type\"] = \"application/json\";\n\n const startedAt = Date.now();\n const res = await fetch(`${baseUrl}${path}`, {\n method,\n headers,\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n const text = await res.text();\n const parsed: unknown = text ? safeParse(text) : undefined;\n\n requests.push({ method, path, status: res.status, ms: Date.now() - startedAt });\n\n if (!res.ok) throw new TestApiError(method, path, res.status, parsed);\n return parsed as T;\n }\n\n return {\n requests,\n get: (path, opts) => call(\"GET\", path, undefined, opts),\n post: (path, body, opts) => call(\"POST\", path, body, opts),\n patch: (path, body, opts) => call(\"PATCH\", path, body, opts),\n put: (path, body, opts) => call(\"PUT\", path, body, opts),\n delete: (path, opts) => call(\"DELETE\", path, undefined, opts),\n query: (path, body, opts) => call(\"QUERY\", path, body, opts),\n\n async signInAs(name) {\n const identity = (config.identities ?? {})[name];\n if (!identity) {\n const declared = Object.keys(config.identities ?? {});\n throw new Error(\n `no test identity named ${JSON.stringify(name)} — the deploy mints one per user declared in ` +\n `config/test-users.ts` +\n (declared.length\n ? `; this run has: ${declared.join(\", \")}`\n : // Not \"you declared none\": from here the two causes are\n // indistinguishable, and blaming the customer's config for a\n // platform failure sends them to look in the wrong file. The\n // deploy log names which one it was.\n \", and this run has none — either your config declares no users \" +\n \"or the deploy could not mint them; the deploy log says which\"),\n );\n }\n // The session the mint already issued — no network call, so switching\n // users is free and the login rail never sees this run.\n if (identity.accessToken) {\n bearer = identity.accessToken;\n return { id: identity.id ?? \"\", email: identity.email };\n }\n return this.signIn(identity);\n },\n\n async signIn(credentials) {\n const result = await call<{ access_token: string; user?: { id: string; email?: string } }>(\n \"POST\",\n \"/auth/login\",\n credentials,\n );\n bearer = result.access_token;\n return result.user ?? { id: \"\" };\n },\n async signOut() {\n await call(\"POST\", \"/auth/logout\", undefined);\n bearer = null;\n },\n asAnonymous() {\n bearer = null;\n },\n };\n}\n\n/** The run's identities, as the deploy passed them. Absent is not an error: a\n * project that declares none still runs every test that needs no login. */\nfunction parseIdentities(raw: string | undefined): Record<string, TestIdentity> {\n if (!raw) return {};\n try {\n return JSON.parse(raw) as Record<string, TestIdentity>;\n } catch {\n return {};\n }\n}\n\nfunction safeParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/** The client the deploy configured, from the environment it set.\n *\n * Constructed lazily so importing this module outside a test run — a typecheck,\n * an editor — does not fail on absent configuration.\n */\nlet configured: TestApi | null = null;\n\nexport const api: TestApi = new Proxy({} as TestApi, {\n get(_target, prop) {\n configured ??= createTestApi({\n baseUrl: process.env.PALBASE_TEST_BASE_URL ?? \"\",\n apiKey: process.env.PALBASE_TEST_API_KEY ?? \"\",\n candidateToken: process.env.PALBASE_TEST_CANDIDATE ?? \"\",\n identities: parseIdentities(process.env.PALBASE_TEST_IDENTITIES),\n });\n return Reflect.get(configured, prop, configured);\n },\n});\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@palbase/backend",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Palbase Backend SDK
|
|
3
|
+
"version": "21.0.1",
|
|
4
|
+
"description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/template/package.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "A Palbase backend
|
|
6
|
+
"description": "A Palbase backend \u2014 class controllers, a declared database, and the secrets it needs.",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "node --test --experimental-strip-types",
|
|
9
9
|
"typecheck": "tsc --noEmit"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@palbase/backend": "^
|
|
12
|
+
"@palbase/backend": "^21.0.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@types/node": "^22",
|