@isahaq/kosh-server 0.0.0-stage → 0.1.0

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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -2
  3. package/dist/api/client.d.ts +18 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +380 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/api/idempotency.d.ts +23 -0
  8. package/dist/api/idempotency.d.ts.map +1 -0
  9. package/dist/api/idempotency.js +52 -0
  10. package/dist/api/idempotency.js.map +1 -0
  11. package/dist/api/metadata.d.ts +65 -0
  12. package/dist/api/metadata.d.ts.map +1 -0
  13. package/dist/api/metadata.js +88 -0
  14. package/dist/api/metadata.js.map +1 -0
  15. package/dist/api/navigation.d.ts +46 -0
  16. package/dist/api/navigation.d.ts.map +1 -0
  17. package/dist/api/navigation.js +131 -0
  18. package/dist/api/navigation.js.map +1 -0
  19. package/dist/api/openapi.d.ts +22 -0
  20. package/dist/api/openapi.d.ts.map +1 -0
  21. package/dist/api/openapi.js +349 -0
  22. package/dist/api/openapi.js.map +1 -0
  23. package/dist/api/rest.d.ts +28 -0
  24. package/dist/api/rest.d.ts.map +1 -0
  25. package/dist/api/rest.js +435 -0
  26. package/dist/api/rest.js.map +1 -0
  27. package/dist/api/views.d.ts +67 -0
  28. package/dist/api/views.d.ts.map +1 -0
  29. package/dist/api/views.js +307 -0
  30. package/dist/api/views.js.map +1 -0
  31. package/dist/audit.d.ts +21 -0
  32. package/dist/audit.d.ts.map +1 -0
  33. package/dist/audit.js +26 -0
  34. package/dist/audit.js.map +1 -0
  35. package/dist/auth/admins.d.ts +26 -0
  36. package/dist/auth/admins.d.ts.map +1 -0
  37. package/dist/auth/admins.js +70 -0
  38. package/dist/auth/admins.js.map +1 -0
  39. package/dist/auth/base.d.ts +12 -0
  40. package/dist/auth/base.d.ts.map +1 -0
  41. package/dist/auth/base.js +192 -0
  42. package/dist/auth/base.js.map +1 -0
  43. package/dist/auth/crypto.d.ts +20 -0
  44. package/dist/auth/crypto.d.ts.map +1 -0
  45. package/dist/auth/crypto.js +100 -0
  46. package/dist/auth/crypto.js.map +1 -0
  47. package/dist/auth/install.d.ts +24 -0
  48. package/dist/auth/install.d.ts.map +1 -0
  49. package/dist/auth/install.js +18 -0
  50. package/dist/auth/install.js.map +1 -0
  51. package/dist/auth/routes.d.ts +20 -0
  52. package/dist/auth/routes.d.ts.map +1 -0
  53. package/dist/auth/routes.js +56 -0
  54. package/dist/auth/routes.js.map +1 -0
  55. package/dist/auth/service.d.ts +108 -0
  56. package/dist/auth/service.d.ts.map +1 -0
  57. package/dist/auth/service.js +477 -0
  58. package/dist/auth/service.js.map +1 -0
  59. package/dist/errors.d.ts +21 -0
  60. package/dist/errors.d.ts.map +1 -0
  61. package/dist/errors.js +78 -0
  62. package/dist/errors.js.map +1 -0
  63. package/dist/identity.d.ts +49 -0
  64. package/dist/identity.d.ts.map +1 -0
  65. package/dist/identity.js +58 -0
  66. package/dist/identity.js.map +1 -0
  67. package/dist/index.d.ts +26 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +26 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/orm.d.ts +14 -0
  72. package/dist/orm.d.ts.map +1 -0
  73. package/dist/orm.js +12 -0
  74. package/dist/orm.js.map +1 -0
  75. package/dist/registry.d.ts +19 -0
  76. package/dist/registry.d.ts.map +1 -0
  77. package/dist/registry.js +26 -0
  78. package/dist/registry.js.map +1 -0
  79. package/dist/server.d.ts +93 -0
  80. package/dist/server.d.ts.map +1 -0
  81. package/dist/server.js +223 -0
  82. package/dist/server.js.map +1 -0
  83. package/dist/telemetry.d.ts +23 -0
  84. package/dist/telemetry.d.ts.map +1 -0
  85. package/dist/telemetry.js +38 -0
  86. package/dist/telemetry.js.map +1 -0
  87. package/package.json +63 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Isahaq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # @isahaq/kosh-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Fastify app, module loader, REST/OpenAPI generator, auth, Shutro environment per request (RFC 0002)
4
+
5
+ Part of [Kosh](https://github.com/isahaq1/kosh), a modern ERP framework on TypeScript, PostgreSQL and the
6
+ [Shutro](https://github.com/isahaq1/shutro) ORM.
7
+
8
+ ```sh
9
+ pnpm add @isahaq/kosh-server
10
+ ```
11
+
12
+ Documentation: [docs/server.md](https://github.com/isahaq1/kosh/blob/main/docs/server.md) · design: [RFCs](https://github.com/isahaq1/kosh/tree/main/docs/rfcs)
13
+
14
+ Node.js 24 or later; ESM only.
15
+
16
+ ## Contact
17
+
18
+ Isahaq · hmisahaq01@gmail.com · +8801852376598
19
+
20
+ ## License
21
+
22
+ MIT
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The typed client generator (RFC 0002 §6.4, `kosh generate client`): turns the OpenAPI document of an installed
3
+ * module set into one self-contained TypeScript module, with no imports, that browsers and Node both run:
4
+ *
5
+ * - an interface per model (read, create, update) and per shared schema (Problem, Money, AggregateQuery, ...);
6
+ * - `Models`, the map from model name to those types and its transitions;
7
+ * - `createClient()`: typed calls for every generated route and the auth routes, sessions by cookie (with the
8
+ * CSRF header) or Bearer token, `KoshApiError` carrying the RFC 9457 problem.
9
+ *
10
+ * Generated code is deterministic (sorted), so regenerating without changes gives the same file.
11
+ */
12
+ type Schema = Readonly<Record<string, unknown>>;
13
+ /** The TypeScript type of a JSON Schema (the subset `openApiDocument` emits). */
14
+ export declare function typeOf(schema: Schema, indent?: string): string;
15
+ /** Generates the client module for an OpenAPI document produced by `openApiDocument`. */
16
+ export declare function generateClient(document: Schema): string;
17
+ export {};
18
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,KAAK,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAgBhD,iFAAiF;AACjF,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,SAAK,GAAG,MAAM,CAgC1D;AAyCD,yFAAyF;AACzF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA8CvD"}
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The typed client generator (RFC 0002 §6.4, `kosh generate client`): turns the OpenAPI document of an installed
3
+ * module set into one self-contained TypeScript module, with no imports, that browsers and Node both run:
4
+ *
5
+ * - an interface per model (read, create, update) and per shared schema (Problem, Money, AggregateQuery, ...);
6
+ * - `Models`, the map from model name to those types and its transitions;
7
+ * - `createClient()`: typed calls for every generated route and the auth routes, sessions by cookie (with the
8
+ * CSRF header) or Bearer token, `KoshApiError` carrying the RFC 9457 problem.
9
+ *
10
+ * Generated code is deterministic (sorted), so regenerating without changes gives the same file.
11
+ */
12
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
13
+ function propertyName(key) {
14
+ return IDENTIFIER.test(key) ? key : JSON.stringify(key);
15
+ }
16
+ function refName(ref) {
17
+ return ref.replace(/^#\/components\/schemas\//, '');
18
+ }
19
+ function asSchema(value) {
20
+ return typeof value === 'object' && value !== null ? value : {};
21
+ }
22
+ /** The TypeScript type of a JSON Schema (the subset `openApiDocument` emits). */
23
+ export function typeOf(schema, indent = '') {
24
+ if (typeof schema['$ref'] === 'string')
25
+ return refName(schema['$ref']);
26
+ if (Array.isArray(schema['oneOf']))
27
+ return schema['oneOf'].map((s) => typeOf(asSchema(s), indent)).join(' | ');
28
+ if (Array.isArray(schema['enum']))
29
+ return schema['enum'].map((v) => JSON.stringify(v)).join(' | ');
30
+ const types = Array.isArray(schema['type']) ? schema['type'] : [schema['type']];
31
+ return types
32
+ .map((type) => {
33
+ switch (type) {
34
+ case 'string':
35
+ return 'string';
36
+ case 'integer':
37
+ case 'number':
38
+ return 'number';
39
+ case 'boolean':
40
+ return 'boolean';
41
+ case 'null':
42
+ return 'null';
43
+ case 'array': {
44
+ const item = typeOf(asSchema(schema['items']), indent);
45
+ return /^[\w.]+$/.test(item) ? `${item}[]` : `(${item})[]`;
46
+ }
47
+ case 'object':
48
+ return schema['properties'] === undefined
49
+ ? 'Record<string, unknown>'
50
+ : objectType(schema, indent);
51
+ default:
52
+ return 'unknown';
53
+ }
54
+ })
55
+ .join(' | ');
56
+ }
57
+ function doc(schema, indent) {
58
+ const parts = [schema['title'], schema['description']].filter((p) => typeof p === 'string' && p !== '');
59
+ return parts.length === 0 ? '' : `${indent}/** ${parts.join(': ').replaceAll('*/', '* /')} */\n`;
60
+ }
61
+ function objectType(schema, indent) {
62
+ const properties = asSchema(schema['properties']);
63
+ const required = new Set(Array.isArray(schema['required']) ? schema['required'] : []);
64
+ const inner = `${indent} `;
65
+ const lines = Object.entries(properties).map(([key, value]) => {
66
+ const s = asSchema(value);
67
+ const optional = required.has(key) ? '' : '?';
68
+ return `${doc(s, inner)}${inner}${propertyName(key)}${optional}: ${typeOf(s, inner)};`;
69
+ });
70
+ return lines.length === 0 ? 'Record<string, never>' : `{\n${lines.join('\n')}\n${indent}}`;
71
+ }
72
+ /** Model name → schema name and transitions, read back from the generated paths. */
73
+ function modelsOf(paths) {
74
+ const models = new Map();
75
+ for (const [path, item] of Object.entries(paths)) {
76
+ const list = /^\/api\/v1\/models\/([^/{}]+)$/.exec(path);
77
+ const operation = asSchema(asSchema(item)['get'])['operationId'];
78
+ if (list?.[1] !== undefined && typeof operation === 'string' && operation.startsWith('list'))
79
+ models.set(list[1], { name: list[1], schema: operation.slice(4), transitions: [] });
80
+ }
81
+ for (const path of Object.keys(paths)) {
82
+ const t = /^\/api\/v1\/models\/([^/{}]+)\/\{id\}\/transitions\/([^/{}]+)$/.exec(path);
83
+ if (t?.[1] !== undefined && t[2] !== undefined)
84
+ models.get(t[1])?.transitions.push(t[2]);
85
+ }
86
+ return [...models.values()]
87
+ .sort((a, b) => a.name.localeCompare(b.name))
88
+ .map((m) => ({ ...m, transitions: m.transitions.sort() }));
89
+ }
90
+ /** Generates the client module for an OpenAPI document produced by `openApiDocument`. */
91
+ export function generateClient(document) {
92
+ const info = asSchema(document['info']);
93
+ const schemas = asSchema(asSchema(document['components'])['schemas']);
94
+ const models = modelsOf(asSchema(document['paths']));
95
+ const out = [];
96
+ out.push(`// Generated by \`kosh generate client\` from ${JSON.stringify(String(info['title'] ?? 'Kosh API'))} ${String(info['version'] ?? '')}. Do not edit: regenerate.`, '', '// ------------------------------------------------------------------------------------------------- schemas', '');
97
+ for (const name of Object.keys(schemas).sort()) {
98
+ const schema = asSchema(schemas[name]);
99
+ const body = typeOf(schema);
100
+ const declaration = body.startsWith('{')
101
+ ? `export interface ${name} ${body}`
102
+ : `export type ${name} = ${body};`;
103
+ out.push(`${doc(schema, '')}${declaration}`, '');
104
+ }
105
+ out.push('// -------------------------------------------------------------------------------------------------- models', '', '/** Every model this API serves: its read, create and update shapes, and its state transitions. */', 'export interface Models {');
106
+ for (const m of models)
107
+ out.push(` ${JSON.stringify(m.name)}: {`, ` record: ${m.schema};`, ` create: ${m.schema}Create;`, ` update: ${m.schema}Update;`, ` transitions: ${m.transitions.length === 0 ? 'never' : m.transitions.map((t) => JSON.stringify(t)).join(' | ')};`, ' };');
108
+ out.push('}', '', '/** The model names, at runtime. */', `export const MODEL_NAMES: readonly ModelName[] = ${JSON.stringify(models.map((m) => m.name))};`, '', RUNTIME);
109
+ return `${out
110
+ .filter((line, i, all) => !(line === '' && all[i - 1] === ''))
111
+ .join('\n')
112
+ .trimEnd()}\n`;
113
+ }
114
+ /** The runtime part of the generated module (plain TypeScript, erasable syntax only). */
115
+ const RUNTIME = `// ------------------------------------------------------------------------------------------------- runtime
116
+
117
+ export type ModelName = keyof Models;
118
+ export type RecordOf<M extends ModelName> = Models[M]['record'];
119
+ export type CreateOf<M extends ModelName> = Models[M]['create'];
120
+ export type UpdateOf<M extends ModelName> = Models[M]['update'];
121
+ export type TransitionOf<M extends ModelName> = Models[M]['transitions'];
122
+
123
+ /** The fields a list returns: all default fields, or \`id\` plus the requested ones. */
124
+ export type Selected<R, K extends keyof R> = [K] extends [never] ? R : Pick<R, K | ('id' & keyof R)>;
125
+
126
+ /** Search parameters. \`filter\` is a Shutro domain, e.g. \`{ name: { ilike: 'acme%' } }\`. */
127
+ export interface ListQuery<R, K extends keyof R> {
128
+ readonly filter?: Readonly<Record<string, unknown>>;
129
+ readonly order?: string | readonly string[];
130
+ readonly fields?: readonly K[];
131
+ readonly limit?: number;
132
+ readonly offset?: number;
133
+ readonly count?: boolean;
134
+ }
135
+
136
+ export interface ListResult<T> {
137
+ readonly items: T[];
138
+ /** With \`count: true\`. */
139
+ readonly count?: number;
140
+ }
141
+
142
+ /** A record with its ETag, to send back as \`ifMatch\` (412 when it changed since). */
143
+ export interface Versioned<T> {
144
+ readonly record: T;
145
+ readonly etag: string | null;
146
+ }
147
+
148
+ export interface WriteOptions {
149
+ /** A retried request with the same key returns the first response. */
150
+ readonly idempotencyKey?: string;
151
+ /** The ETag read with the record. */
152
+ readonly ifMatch?: string;
153
+ }
154
+
155
+ /** The caller's view of a model (\`GET /api/v1/meta/{model}\`). */
156
+ export interface ModelMeta {
157
+ readonly name: string;
158
+ readonly label: string;
159
+ readonly operations: Readonly<Record<'read' | 'write' | 'create' | 'unlink' | 'export' | 'import', boolean>>;
160
+ readonly transitions: readonly string[];
161
+ readonly onchange: readonly string[];
162
+ readonly fields: readonly {
163
+ readonly key: string;
164
+ readonly kind: string;
165
+ readonly label: string;
166
+ readonly required: boolean;
167
+ readonly readonly: boolean;
168
+ readonly target?: string;
169
+ readonly options?: readonly { readonly value: string; readonly label: string }[];
170
+ }[];
171
+ }
172
+
173
+ /** Typed calls on one model. */
174
+ export interface ModelClient<M extends ModelName> {
175
+ list<K extends keyof RecordOf<M> & string = never>(
176
+ query?: ListQuery<RecordOf<M>, K>,
177
+ ): Promise<ListResult<Selected<RecordOf<M>, K>>>;
178
+ get(id: number): Promise<Versioned<RecordOf<M>>>;
179
+ create(values: CreateOf<M>, options?: WriteOptions): Promise<Versioned<RecordOf<M>>>;
180
+ update(id: number, values: UpdateOf<M>, options?: WriteOptions): Promise<Versioned<RecordOf<M>>>;
181
+ delete(id: number, options?: WriteOptions): Promise<void>;
182
+ transition(id: number, name: TransitionOf<M>, options?: WriteOptions): Promise<Versioned<RecordOf<M>>>;
183
+ aggregate(query: AggregateQuery): Promise<Record<string, unknown>[]>;
184
+ onchange(input: OnchangeInput): Promise<OnchangeResult>;
185
+ meta(): Promise<ModelMeta>;
186
+ }
187
+
188
+ export interface SignIn {
189
+ readonly token: string;
190
+ readonly csrfToken: string;
191
+ readonly twoFactorRequired: boolean;
192
+ }
193
+
194
+ export interface CurrentUser {
195
+ readonly id: number;
196
+ readonly login: string;
197
+ readonly name: string;
198
+ readonly company: number | null;
199
+ readonly companies: readonly number[];
200
+ readonly twoFactorEnabled: boolean;
201
+ }
202
+
203
+ /** The auth routes; sign-in keeps the session (token or CSRF token) in the client. */
204
+ export interface AuthClient {
205
+ login(login: string, password: string): Promise<SignIn>;
206
+ verifyTwoFactor(code: string): Promise<void>;
207
+ logout(): Promise<void>;
208
+ me(): Promise<CurrentUser>;
209
+ setupTwoFactor(): Promise<{ readonly secret: string; readonly otpauthUri: string }>;
210
+ enableTwoFactor(code: string): Promise<{ readonly recoveryCodes: readonly string[] }>;
211
+ disableTwoFactor(password: string, code: string): Promise<void>;
212
+ changePassword(current: string, next: string): Promise<void>;
213
+ }
214
+
215
+ export interface KoshClient {
216
+ model<M extends ModelName>(name: M): ModelClient<M>;
217
+ readonly auth: AuthClient;
218
+ /** The company of later requests (\`X-Company\`); \`undefined\`: the user's default. */
219
+ setCompany(company: number | undefined): void;
220
+ }
221
+
222
+ export interface ClientOptions {
223
+ /** Default: same origin. */
224
+ readonly baseUrl?: string;
225
+ /** \`cookie\` (browsers: the session cookie plus the CSRF header) or \`bearer\` (scripts, servers). Default: cookie. */
226
+ readonly session?: 'cookie' | 'bearer';
227
+ /** An existing session token (bearer) or CSRF token (cookie). */
228
+ readonly token?: string;
229
+ readonly csrfToken?: string;
230
+ readonly company?: number;
231
+ readonly headers?: Readonly<Record<string, string>>;
232
+ readonly fetch?: typeof fetch;
233
+ }
234
+
235
+ /** A refused request: the RFC 9457 problem the server returned. */
236
+ export class KoshApiError extends Error {
237
+ readonly status: number;
238
+ readonly problem: Problem;
239
+ constructor(problem: Problem) {
240
+ super(problem.hint === undefined ? problem.detail : \`\${problem.detail} \${problem.hint}\`);
241
+ this.name = 'KoshApiError';
242
+ this.status = problem.status;
243
+ this.problem = problem;
244
+ }
245
+ }
246
+
247
+ interface Call {
248
+ readonly query?: Readonly<Record<string, string | undefined>>;
249
+ readonly body?: unknown;
250
+ readonly headers?: Readonly<Record<string, string | undefined>>;
251
+ }
252
+
253
+ /** Creates a client. */
254
+ export function createClient(options: ClientOptions = {}): KoshClient {
255
+ const base = (options.baseUrl ?? '').replace(/\\/$/, '');
256
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
257
+ const mode = options.session ?? 'cookie';
258
+ let token = options.token;
259
+ let csrf = options.csrfToken;
260
+ let company = options.company;
261
+
262
+ async function call<T>(method: string, path: string, c: Call = {}): Promise<{ body: T; headers: Headers }> {
263
+ const params = new URLSearchParams();
264
+ for (const [k, v] of Object.entries(c.query ?? {})) if (v !== undefined) params.set(k, v);
265
+ const headers: Record<string, string> = { accept: 'application/json', ...options.headers };
266
+ for (const [k, v] of Object.entries(c.headers ?? {})) if (v !== undefined) headers[k] = v;
267
+ if (c.body !== undefined) headers['content-type'] = 'application/json';
268
+ if (company !== undefined) headers['x-company'] = String(company);
269
+ if (mode === 'bearer' && token !== undefined) headers['authorization'] = \`Bearer \${token}\`;
270
+ if (mode === 'cookie' && csrf !== undefined && method !== 'GET') headers['x-csrf-token'] = csrf;
271
+ const search = params.size === 0 ? '' : \`?\${params.toString()}\`;
272
+ const response = await doFetch(\`\${base}\${path}\${search}\`, {
273
+ method,
274
+ headers,
275
+ ...(c.body === undefined ? {} : { body: JSON.stringify(c.body) }),
276
+ ...(mode === 'cookie' ? { credentials: 'include' as const } : {}),
277
+ });
278
+ const text = await response.text();
279
+ let body: unknown = null;
280
+ try {
281
+ body = text === '' ? null : JSON.parse(text);
282
+ } catch {
283
+ body = null;
284
+ }
285
+ if (!response.ok) {
286
+ const p = typeof body === 'object' && body !== null && 'type' in body ? (body as Problem) : undefined;
287
+ throw new KoshApiError(
288
+ p ?? { type: 'about:blank', title: response.statusText, status: response.status, detail: text.slice(0, 500) },
289
+ );
290
+ }
291
+ return { body: body as T, headers: response.headers };
292
+ }
293
+
294
+ const versioned = async <T>(pending: Promise<{ body: T; headers: Headers }>): Promise<Versioned<T>> => {
295
+ const { body, headers } = await pending;
296
+ return { record: body, etag: headers.get('etag') };
297
+ };
298
+ const writeHeaders = (o: WriteOptions = {}): Record<string, string | undefined> => ({
299
+ 'idempotency-key': o.idempotencyKey,
300
+ 'if-match': o.ifMatch,
301
+ });
302
+
303
+ function model<M extends ModelName>(name: M): ModelClient<M> {
304
+ const root = \`/api/v1/models/\${encodeURIComponent(name)}\`;
305
+ return {
306
+ async list(query = {}) {
307
+ const order = typeof query.order === 'string' ? query.order : query.order?.join(',');
308
+ const { body } = await call<ListResult<never>>('GET', root, {
309
+ query: {
310
+ filter: query.filter === undefined ? undefined : JSON.stringify(query.filter),
311
+ order,
312
+ fields: query.fields?.join(','),
313
+ limit: query.limit === undefined ? undefined : String(query.limit),
314
+ offset: query.offset === undefined ? undefined : String(query.offset),
315
+ count: query.count === true ? 'true' : undefined,
316
+ },
317
+ });
318
+ return body;
319
+ },
320
+ get: (id) => versioned(call('GET', \`\${root}/\${id}\`)),
321
+ create: (values, o) => versioned(call('POST', root, { body: values, headers: writeHeaders(o) })),
322
+ update: (id, values, o) => versioned(call('PATCH', \`\${root}/\${id}\`, { body: values, headers: writeHeaders(o) })),
323
+ async delete(id, o) {
324
+ await call('DELETE', \`\${root}/\${id}\`, { headers: writeHeaders(o) });
325
+ },
326
+ transition: (id, t, o) =>
327
+ versioned(call('POST', \`\${root}/\${id}/transitions/\${encodeURIComponent(t)}\`, { headers: writeHeaders(o) })),
328
+ async aggregate(query) {
329
+ return (await call<{ rows: Record<string, unknown>[] }>('POST', \`\${root}/aggregate\`, { body: query })).body.rows;
330
+ },
331
+ async onchange(input) {
332
+ return (await call<OnchangeResult>('POST', \`\${root}/onchange\`, { body: input })).body;
333
+ },
334
+ async meta() {
335
+ return (await call<ModelMeta>('GET', \`/api/v1/meta/\${encodeURIComponent(name)}\`)).body;
336
+ },
337
+ };
338
+ }
339
+
340
+ const auth: AuthClient = {
341
+ async login(login, password) {
342
+ const { body } = await call<SignIn>('POST', '/api/v1/auth/login', { body: { login, password } });
343
+ if (mode === 'bearer') token = body.token;
344
+ else csrf = body.csrfToken;
345
+ return body;
346
+ },
347
+ async verifyTwoFactor(code) {
348
+ await call('POST', '/api/v1/auth/2fa/verify', { body: { code } });
349
+ },
350
+ async logout() {
351
+ await call('POST', '/api/v1/auth/logout');
352
+ token = undefined;
353
+ csrf = undefined;
354
+ },
355
+ async me() {
356
+ return (await call<CurrentUser>('GET', '/api/v1/auth/me')).body;
357
+ },
358
+ async setupTwoFactor() {
359
+ return (await call<{ secret: string; otpauthUri: string }>('POST', '/api/v1/auth/2fa/setup')).body;
360
+ },
361
+ async enableTwoFactor(code) {
362
+ return (await call<{ recoveryCodes: string[] }>('POST', '/api/v1/auth/2fa/enable', { body: { code } })).body;
363
+ },
364
+ async disableTwoFactor(password, code) {
365
+ await call('POST', '/api/v1/auth/2fa/disable', { body: { password, code } });
366
+ },
367
+ async changePassword(current, next) {
368
+ await call('POST', '/api/v1/auth/password', { body: { current, next } });
369
+ },
370
+ };
371
+
372
+ return {
373
+ model,
374
+ auth,
375
+ setCompany(next) {
376
+ company = next;
377
+ },
378
+ };
379
+ }`;
380
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ import type { Driver, Env } from '../orm.js';
3
+ export declare const IDEMPOTENCY_TABLE = "kosh_idempotency";
4
+ /** Statements creating the idempotency table (run at server start). */
5
+ export declare function idempotencyTableStatements(): readonly {
6
+ readonly text: string;
7
+ readonly params: readonly [];
8
+ }[];
9
+ /** A handler's result: status and JSON body. */
10
+ export interface Outcome {
11
+ readonly status: number;
12
+ readonly body: unknown;
13
+ readonly headers?: Readonly<Record<string, string>>;
14
+ /** Set when the stored response of an earlier request is replayed. */
15
+ readonly replayed?: boolean;
16
+ }
17
+ /**
18
+ * Runs `compute` at most once per idempotency key (in the request's transaction `env`). Without a key it just runs.
19
+ * @throws {HttpProblem} 400 for a malformed key, 409 while the first request with the key is still running,
20
+ * 422 when the key was used for a different request.
21
+ */
22
+ export declare function idempotent(request: FastifyRequest, env: Env, driver: Driver, compute: () => Promise<Outcome>): Promise<Outcome>;
23
+ //# sourceMappingURL=idempotency.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../../src/api/idempotency.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,OAAO,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAE7C,eAAO,MAAM,iBAAiB,qBAAqB,CAAC;AAEpD,uEAAuE;AACvE,wBAAgB,0BAA0B,IAAI,SAAS;IACrD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;CAC9B,EAAE,CAOF;AAED,gDAAgD;AAChD,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B;AAID;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,GAAG,EACR,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAC9B,OAAO,CAAC,OAAO,CAAC,CAkDlB"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Idempotency keys (RFC 0002 §6.4, CLAUDE.md robustness): an `Idempotency-Key` header on an unsafe request stores the
3
+ * key and, in the same transaction, the response. A retry with the same key replays the stored response instead of
4
+ * acting twice; the same key with a different request is refused; a failed request stores nothing (the transaction
5
+ * rolls back), so its retry runs again. Keys are per user.
6
+ */
7
+ import { createHash } from 'node:crypto';
8
+ import { HttpProblem } from '../errors.js';
9
+ export const IDEMPOTENCY_TABLE = 'kosh_idempotency';
10
+ /** Statements creating the idempotency table (run at server start). */
11
+ export function idempotencyTableStatements() {
12
+ return [
13
+ {
14
+ text: `CREATE TABLE IF NOT EXISTS ${IDEMPOTENCY_TABLE} ("user_id" bigint NOT NULL, "key" text NOT NULL, "fingerprint" text NOT NULL, "status" integer, "response" jsonb, "created_at" timestamp with time zone NOT NULL DEFAULT now(), PRIMARY KEY ("user_id", "key"))`,
15
+ params: [],
16
+ },
17
+ ];
18
+ }
19
+ const KEY = /^[\w.:-]{1,128}$/;
20
+ /**
21
+ * Runs `compute` at most once per idempotency key (in the request's transaction `env`). Without a key it just runs.
22
+ * @throws {HttpProblem} 400 for a malformed key, 409 while the first request with the key is still running,
23
+ * 422 when the key was used for a different request.
24
+ */
25
+ export async function idempotent(request, env, driver, compute) {
26
+ const header = request.headers['idempotency-key'];
27
+ if (header === undefined)
28
+ return compute();
29
+ const key = Array.isArray(header) ? header[0] : header;
30
+ if (key === undefined || !KEY.test(key))
31
+ throw new HttpProblem(400, 'invalid-idempotency-key', 'Invalid idempotency key', 'Idempotency-Key must be 1–128 characters of letters, digits, _ . : -.');
32
+ const fingerprint = createHash('sha256')
33
+ .update(`${request.method} ${request.url.split('?')[0] ?? ''} ${JSON.stringify(request.body ?? null)}`)
34
+ .digest('hex');
35
+ const claimed = await env.sql `INSERT INTO kosh_idempotency (user_id, key, fingerprint) VALUES (${env.uid}, ${key}, ${fingerprint}) ON CONFLICT DO NOTHING RETURNING key`;
36
+ if (claimed.length === 0) {
37
+ // Another request with this key committed (this one waited on it): read it with a fresh snapshot.
38
+ const [row] = await driver.query({
39
+ text: 'SELECT fingerprint, status, response FROM kosh_idempotency WHERE user_id = $1 AND key = $2',
40
+ params: [env.uid, key],
41
+ });
42
+ if (row === undefined || row.status === null)
43
+ throw new HttpProblem(409, 'idempotency-in-progress', 'Request in progress', `A request with Idempotency-Key ${key} is still running.`, 'Retry shortly.');
44
+ if (row.fingerprint !== fingerprint)
45
+ throw new HttpProblem(422, 'idempotency-key-reused', 'Idempotency key reused', `Idempotency-Key ${key} was used for a different request.`, 'Use a new key for each distinct request.');
46
+ return { status: row.status, body: row.response, replayed: true };
47
+ }
48
+ const outcome = await compute();
49
+ await env.sql `UPDATE kosh_idempotency SET status = ${outcome.status}, response = (${JSON.stringify(outcome.body ?? null)})::jsonb WHERE user_id = ${env.uid} AND key = ${key}`;
50
+ return outcome;
51
+ }
52
+ //# sourceMappingURL=idempotency.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../../src/api/idempotency.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAG3C,MAAM,CAAC,MAAM,iBAAiB,GAAG,kBAAkB,CAAC;AAEpD,uEAAuE;AACvE,MAAM,UAAU,0BAA0B;IAIxC,OAAO;QACL;YACE,IAAI,EAAE,8BAA8B,iBAAiB,kNAAkN;YACvQ,MAAM,EAAE,EAAE;SACX;KACF,CAAC;AACJ,CAAC;AAWD,MAAM,GAAG,GAAG,kBAAkB,CAAC;AAE/B;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,OAAuB,EACvB,GAAQ,EACR,MAAc,EACd,OAA+B;IAE/B,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAClD,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,OAAO,EAAE,CAAC;IAC3C,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IACvD,IAAI,GAAG,KAAK,SAAS,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC;QACrC,MAAM,IAAI,WAAW,CACnB,GAAG,EACH,yBAAyB,EACzB,yBAAyB,EACzB,uEAAuE,CACxE,CAAC;IACJ,MAAM,WAAW,GAAG,UAAU,CAAC,QAAQ,CAAC;SACrC,MAAM,CACL,GAAG,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC,EAAE,CAC/F;SACA,MAAM,CAAC,KAAK,CAAC,CAAC;IACjB,MAAM,OAAO,GAAG,MAAM,GAAG,CAAC,GAAG,CAE3B,oEAAoE,GAAG,CAAC,GAAG,KAAK,GAAG,KAAK,WAAW,wCAAwC,CAAC;IAC9I,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,kGAAkG;QAClG,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,MAAM,CAAC,KAAK,CAI7B;YACD,IAAI,EAAE,4FAA4F;YAClG,MAAM,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC;SACvB,CAAC,CAAC;QACH,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,MAAM,KAAK,IAAI;YAC1C,MAAM,IAAI,WAAW,CACnB,GAAG,EACH,yBAAyB,EACzB,qBAAqB,EACrB,kCAAkC,GAAG,oBAAoB,EACzD,gBAAgB,CACjB,CAAC;QACJ,IAAI,GAAG,CAAC,WAAW,KAAK,WAAW;YACjC,MAAM,IAAI,WAAW,CACnB,GAAG,EACH,wBAAwB,EACxB,wBAAwB,EACxB,mBAAmB,GAAG,oCAAoC,EAC1D,0CAA0C,CAC3C,CAAC;QACJ,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,GAAG,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IACpE,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,OAAO,EAAE,CAAC;IAChC,MAAM,GAAG,CAAC,GAAG,CAAA,wCAAwC,OAAO,CAAC,MAAM,iBAAiB,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC,4BAA4B,GAAG,CAAC,GAAG,cAAc,GAAG,EAAE,CAAC;IAC/K,OAAO,OAAO,CAAC;AACjB,CAAC"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Model metadata for the generated API (RFC 0002 §6.3–6.4), read from Shutro's registry: `registry.describe()` plus
3
+ * the field descriptors for what `describe()` does not normalize yet (selection values, state transitions;
4
+ * docs/shutro-requests.md #1). Computed once per registry; per-user views are filtered at request time.
5
+ */
6
+ import type { Registry } from '../orm.js';
7
+ /** One field as the API exposes it. */
8
+ export interface ApiField {
9
+ readonly key: string;
10
+ readonly kind: string;
11
+ readonly label: string;
12
+ readonly required: boolean;
13
+ /** Not writable through the API (readonly, computed without inverse, implicit). */
14
+ readonly readonly: boolean;
15
+ /** Has a column: listable, filterable, sortable. */
16
+ readonly stored: boolean;
17
+ /** Required when creating (required, no default, not computed). */
18
+ readonly requiredOnCreate: boolean;
19
+ readonly target: string | null;
20
+ /** `[value, label]` for selection and state fields with a static list. */
21
+ readonly options: readonly (readonly [string, string])[] | null;
22
+ readonly translate: boolean;
23
+ }
24
+ /** A state transition. */
25
+ export interface TransitionSpec {
26
+ readonly name: string;
27
+ readonly field: string;
28
+ readonly from: readonly string[];
29
+ readonly to: string;
30
+ /** A feature permission the user needs (`definePermission`), or `null`. */
31
+ readonly requires: string | null;
32
+ }
33
+ /** One model as the API exposes it. */
34
+ export interface ApiModel {
35
+ readonly name: string;
36
+ readonly label: string;
37
+ /** `res.partner` → `ResPartner` (OpenAPI component names). */
38
+ readonly schemaName: string;
39
+ readonly fields: readonly ApiField[];
40
+ readonly byKey: ReadonlyMap<string, ApiField>;
41
+ /** Transition names (state fields). */
42
+ readonly transitions: readonly string[];
43
+ /** The transitions in detail: their state field, source and target states, and required permission. */
44
+ readonly transitionSpecs: readonly TransitionSpec[];
45
+ /** Fields with onchange handlers. */
46
+ readonly onchange: readonly string[];
47
+ /** The stored field names are searched by (`name` by default), or `null`. */
48
+ readonly recName: string | null;
49
+ }
50
+ /** `res.partner` → `ResPartner`. */
51
+ export declare function schemaName(model: string): string;
52
+ /** The API metadata of every model of the registry. */
53
+ export declare function apiModels(registry: Registry): ReadonlyMap<string, ApiModel>;
54
+ /** Fields listed by default: stored, not binary, not x2many. */
55
+ export declare function defaultFields(model: ApiModel): readonly ApiField[];
56
+ /** The models the generated API serves: every installed model except the modules' `privateModels`. */
57
+ export declare function publicApiModels(registry: {
58
+ readonly loaded: {
59
+ readonly modules: readonly {
60
+ readonly privateModels?: readonly string[];
61
+ }[];
62
+ };
63
+ readonly registry: Registry;
64
+ }): ReadonlyMap<string, ApiModel>;
65
+ //# sourceMappingURL=metadata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../../src/api/metadata.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE1C,uCAAuC;AACvC,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,oDAAoD;IACpD,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,mEAAmE;IACnE,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC;IAChE,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;CAC7B;AAED,0BAA0B;AAC1B,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,uCAAuC;AACvC,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS,QAAQ,EAAE,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAC9C,uCAAuC;IACvC,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,uGAAuG;IACvG,QAAQ,CAAC,eAAe,EAAE,SAAS,cAAc,EAAE,CAAC;IACpD,qCAAqC;IACrC,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAWD,oCAAoC;AACpC,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKhD;AAED,uDAAuD;AACvD,wBAAgB,SAAS,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CA6D3E;AASD,gEAAgE;AAChE,wBAAgB,aAAa,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,QAAQ,EAAE,CAIlE;AAED,sGAAsG;AACtG,wBAAgB,eAAe,CAAC,QAAQ,EAAE;IACxC,QAAQ,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,SAAS;YAAE,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;IACjG,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;CAC7B,GAAG,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CAGhC"}