@pwfabric/cli 0.0.0-stage → 1.0.0-rc.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.
Files changed (129) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +122 -2
  3. package/dist/args.d.ts +11 -0
  4. package/dist/args.js +43 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/bin.d.ts +2 -0
  7. package/dist/bin.js +10 -0
  8. package/dist/bin.js.map +1 -0
  9. package/dist/commands/add.d.ts +3 -0
  10. package/dist/commands/add.js +56 -0
  11. package/dist/commands/add.js.map +1 -0
  12. package/dist/commands/build.d.ts +3 -0
  13. package/dist/commands/build.js +55 -0
  14. package/dist/commands/build.js.map +1 -0
  15. package/dist/commands/create.d.ts +9 -0
  16. package/dist/commands/create.js +119 -0
  17. package/dist/commands/create.js.map +1 -0
  18. package/dist/commands/dev.d.ts +12 -0
  19. package/dist/commands/dev.js +107 -0
  20. package/dist/commands/dev.js.map +1 -0
  21. package/dist/commands/lib/flags.d.ts +10 -0
  22. package/dist/commands/lib/flags.js +33 -0
  23. package/dist/commands/lib/flags.js.map +1 -0
  24. package/dist/commands/lib/identity.d.ts +38 -0
  25. package/dist/commands/lib/identity.js +120 -0
  26. package/dist/commands/lib/identity.js.map +1 -0
  27. package/dist/commands/lib/project.d.ts +34 -0
  28. package/dist/commands/lib/project.js +78 -0
  29. package/dist/commands/lib/project.js.map +1 -0
  30. package/dist/commands/lib/report.d.ts +15 -0
  31. package/dist/commands/lib/report.js +75 -0
  32. package/dist/commands/lib/report.js.map +1 -0
  33. package/dist/commands/lib/starter.d.ts +41 -0
  34. package/dist/commands/lib/starter.js +136 -0
  35. package/dist/commands/lib/starter.js.map +1 -0
  36. package/dist/commands/lib/static-server.d.ts +45 -0
  37. package/dist/commands/lib/static-server.js +176 -0
  38. package/dist/commands/lib/static-server.js.map +1 -0
  39. package/dist/commands/pack.d.ts +5 -0
  40. package/dist/commands/pack.js +56 -0
  41. package/dist/commands/pack.js.map +1 -0
  42. package/dist/commands/registry.d.ts +7 -0
  43. package/dist/commands/registry.js +16 -0
  44. package/dist/commands/registry.js.map +1 -0
  45. package/dist/commands/run.d.ts +14 -0
  46. package/dist/commands/run.js +79 -0
  47. package/dist/commands/run.js.map +1 -0
  48. package/dist/commands/types.d.ts +34 -0
  49. package/dist/commands/types.js +10 -0
  50. package/dist/commands/types.js.map +1 -0
  51. package/dist/commands/validate.d.ts +6 -0
  52. package/dist/commands/validate.js +33 -0
  53. package/dist/commands/validate.js.map +1 -0
  54. package/dist/dev/assemble.d.ts +47 -0
  55. package/dist/dev/assemble.js +339 -0
  56. package/dist/dev/assemble.js.map +1 -0
  57. package/dist/dev/compile.d.ts +54 -0
  58. package/dist/dev/compile.js +215 -0
  59. package/dist/dev/compile.js.map +1 -0
  60. package/dist/dev/csp.d.ts +67 -0
  61. package/dist/dev/csp.js +260 -0
  62. package/dist/dev/csp.js.map +1 -0
  63. package/dist/dev/diagnostics.d.ts +21 -0
  64. package/dist/dev/diagnostics.js +22 -0
  65. package/dist/dev/diagnostics.js.map +1 -0
  66. package/dist/dev/envelope.d.ts +92 -0
  67. package/dist/dev/envelope.js +154 -0
  68. package/dist/dev/envelope.js.map +1 -0
  69. package/dist/dev/media.d.ts +53 -0
  70. package/dist/dev/media.js +128 -0
  71. package/dist/dev/media.js.map +1 -0
  72. package/dist/dev/needs-host.d.ts +2 -0
  73. package/dist/dev/needs-host.js +32 -0
  74. package/dist/dev/needs-host.js.map +1 -0
  75. package/dist/dev/output.d.ts +9 -0
  76. package/dist/dev/output.js +32 -0
  77. package/dist/dev/output.js.map +1 -0
  78. package/dist/dev/overlay.d.ts +21 -0
  79. package/dist/dev/overlay.js +59 -0
  80. package/dist/dev/overlay.js.map +1 -0
  81. package/dist/dev/server.d.ts +27 -0
  82. package/dist/dev/server.js +268 -0
  83. package/dist/dev/server.js.map +1 -0
  84. package/dist/dev/shell.d.ts +94 -0
  85. package/dist/dev/shell.js +267 -0
  86. package/dist/dev/shell.js.map +1 -0
  87. package/dist/dev/sources.d.ts +45 -0
  88. package/dist/dev/sources.js +165 -0
  89. package/dist/dev/sources.js.map +1 -0
  90. package/dist/dev/theme.d.ts +51 -0
  91. package/dist/dev/theme.js +185 -0
  92. package/dist/dev/theme.js.map +1 -0
  93. package/dist/dev/types.d.ts +40 -0
  94. package/dist/dev/types.js +11 -0
  95. package/dist/dev/types.js.map +1 -0
  96. package/dist/dev/watcher.d.ts +29 -0
  97. package/dist/dev/watcher.js +150 -0
  98. package/dist/dev/watcher.js.map +1 -0
  99. package/dist/host/definitions.d.ts +61 -0
  100. package/dist/host/definitions.js +136 -0
  101. package/dist/host/definitions.js.map +1 -0
  102. package/dist/host/http.d.ts +39 -0
  103. package/dist/host/http.js +156 -0
  104. package/dist/host/http.js.map +1 -0
  105. package/dist/host/local-host.d.ts +59 -0
  106. package/dist/host/local-host.js +420 -0
  107. package/dist/host/local-host.js.map +1 -0
  108. package/dist/host/oidc.d.ts +46 -0
  109. package/dist/host/oidc.js +192 -0
  110. package/dist/host/oidc.js.map +1 -0
  111. package/dist/host/query.d.ts +35 -0
  112. package/dist/host/query.js +158 -0
  113. package/dist/host/query.js.map +1 -0
  114. package/dist/host/receipts.d.ts +25 -0
  115. package/dist/host/receipts.js +72 -0
  116. package/dist/host/receipts.js.map +1 -0
  117. package/dist/host/records.d.ts +73 -0
  118. package/dist/host/records.js +229 -0
  119. package/dist/host/records.js.map +1 -0
  120. package/dist/host/tokens.d.ts +28 -0
  121. package/dist/host/tokens.js +52 -0
  122. package/dist/host/tokens.js.map +1 -0
  123. package/dist/host/types.d.ts +32 -0
  124. package/dist/host/types.js +11 -0
  125. package/dist/host/types.js.map +1 -0
  126. package/dist/index.d.ts +18 -0
  127. package/dist/index.js +51 -0
  128. package/dist/index.js.map +1 -0
  129. package/package.json +66 -3
@@ -0,0 +1,192 @@
1
+ /**
2
+ * A development stand-in for an external OpenID Connect issuer.
3
+ *
4
+ * When `auth.json` declares `topology: "external"`, the app's real issuer
5
+ * cannot be reached from a laptop without registering a redirect URI for
6
+ * every port `pwf dev` picks. The local host therefore plays that issuer, at
7
+ * `<prefix>/oidc`, with just enough of the protocol for the runner's PKCE
8
+ * client (session-protocol §3):
9
+ *
10
+ * GET <prefix>/oidc/.well-known/openid-configuration discovery
11
+ * GET <prefix>/oidc/authorize code + PKCE (S256), no consent screen:
12
+ * the development user is signed in at once
13
+ * POST <prefix>/oidc/token authorization_code, refresh_token
14
+ * (and refresh_token + scope "<audience>/.default"
15
+ * for the door token)
16
+ *
17
+ * The declared `clientId`, scopes and audience are kept; only the issuer
18
+ * address changes. Tokens are opaque; the ID token is an UNSIGNED JWT
19
+ * (`alg: none`), which the runner decodes and does not verify. No real
20
+ * issuer's keys are ever involved, and nothing here is safe to expose beyond
21
+ * the developer's own machine.
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ import { readForm, redirect, sendJson, HostError } from './http.js';
25
+ import { TokenStore } from './tokens.js';
26
+ export const ACCESS_TTL_S = 3600;
27
+ const CODE_TTL_MS = 60_000;
28
+ const REFRESH_TTL_MS = 24 * 3600_000;
29
+ const b64url = (v) => Buffer.from(v).toString('base64url');
30
+ function oauthError(res, status, error, description) {
31
+ sendJson(res, status, { error, error_description: description });
32
+ }
33
+ export class DevIssuer {
34
+ bearer;
35
+ user;
36
+ codes = new TokenStore();
37
+ refresh = new TokenStore();
38
+ constructor(bearer, user) {
39
+ this.bearer = bearer;
40
+ this.user = user;
41
+ }
42
+ reset() {
43
+ this.codes.clear();
44
+ this.refresh.clear();
45
+ }
46
+ /** `<origin><prefix>/oidc` — the `iss` this issuer speaks for. */
47
+ static issuerUrl(origin, prefix) {
48
+ return `${origin.replace(/\/+$/, '')}${prefix}/oidc`;
49
+ }
50
+ discovery(res, issuer) {
51
+ sendJson(res, 200, {
52
+ issuer,
53
+ authorization_endpoint: `${issuer}/authorize`,
54
+ token_endpoint: `${issuer}/token`,
55
+ response_types_supported: ['code'],
56
+ grant_types_supported: ['authorization_code', 'refresh_token'],
57
+ code_challenge_methods_supported: ['S256'],
58
+ subject_types_supported: ['public'],
59
+ id_token_signing_alg_values_supported: ['none'],
60
+ token_endpoint_auth_methods_supported: ['none'],
61
+ });
62
+ }
63
+ authorize(req, res, url, auth) {
64
+ const q = url.searchParams;
65
+ const declared = auth.issuer;
66
+ if (!declared || q.get('client_id') !== declared.clientId) {
67
+ throw new HostError(400, 'UNKNOWN_CLIENT', 'client_id does not match the app declaration.');
68
+ }
69
+ let redirectUri;
70
+ try {
71
+ redirectUri = new URL(q.get('redirect_uri') ?? '');
72
+ }
73
+ catch {
74
+ throw new HostError(400, 'INVALID_REDIRECT_URI', 'redirect_uri must be an absolute URL.');
75
+ }
76
+ // The app is served by the same development server; anything else is not this app.
77
+ if (!/^https?:$/.test(redirectUri.protocol) || redirectUri.host !== req.headers.host) {
78
+ throw new HostError(400, 'INVALID_REDIRECT_URI', 'redirect_uri must be on this development server.');
79
+ }
80
+ const state = q.get('state');
81
+ const back = (params) => {
82
+ const target = new URL(redirectUri);
83
+ for (const [k, v] of Object.entries(params))
84
+ target.searchParams.set(k, v);
85
+ if (state !== null)
86
+ target.searchParams.set('state', state);
87
+ redirect(res, target.toString());
88
+ };
89
+ if (q.get('response_type') !== 'code') {
90
+ back({ error: 'unsupported_response_type', error_description: 'Only response_type=code is supported.' });
91
+ return;
92
+ }
93
+ const challenge = q.get('code_challenge');
94
+ if (!challenge || q.get('code_challenge_method') !== 'S256') {
95
+ back({ error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required.' });
96
+ return;
97
+ }
98
+ const code = this.codes.issue({
99
+ principal: this.user,
100
+ clientId: declared.clientId,
101
+ redirectUri: redirectUri.toString(),
102
+ challenge,
103
+ scopes: (q.get('scope') ?? '').split(' ').filter(Boolean),
104
+ expiresAt: Date.now() + CODE_TTL_MS,
105
+ });
106
+ back({ code });
107
+ }
108
+ async token(req, res, issuer, auth) {
109
+ const form = await readForm(req);
110
+ const clientId = auth.issuer?.clientId;
111
+ if (!clientId || form.get('client_id') !== clientId) {
112
+ oauthError(res, 401, 'invalid_client', 'client_id does not match the app declaration.');
113
+ return;
114
+ }
115
+ const grantType = form.get('grant_type');
116
+ if (grantType === 'authorization_code') {
117
+ const code = this.codes.take(form.get('code') ?? undefined);
118
+ if (!code || code.clientId !== clientId) {
119
+ oauthError(res, 400, 'invalid_grant', 'The code is unknown, used or expired.');
120
+ return;
121
+ }
122
+ if (form.get('redirect_uri') !== code.redirectUri) {
123
+ oauthError(res, 400, 'invalid_grant', 'redirect_uri differs from the one the code was issued for.');
124
+ return;
125
+ }
126
+ const verifier = form.get('code_verifier') ?? '';
127
+ if (b64url(createHash('sha256').update(verifier).digest()) !== code.challenge) {
128
+ oauthError(res, 400, 'invalid_grant', 'code_verifier does not match the code_challenge.');
129
+ return;
130
+ }
131
+ sendJson(res, 200, this.mint(issuer, clientId, code.principal, code.scopes, true));
132
+ return;
133
+ }
134
+ if (grantType === 'refresh_token') {
135
+ const old = this.refresh.take(form.get('refresh_token') ?? undefined);
136
+ if (!old || old.clientId !== clientId) {
137
+ oauthError(res, 400, 'invalid_grant', 'The refresh token is unknown, used or expired.');
138
+ return;
139
+ }
140
+ const scope = form.get('scope');
141
+ if (scope) {
142
+ const audience = auth.issuer?.audience?.replace(/\/+$/, '');
143
+ if (!audience || scope !== `${audience}/.default`) {
144
+ oauthError(res, 400, 'invalid_scope', 'Only the app audience scope "<audience>/.default" can be redeemed.');
145
+ return;
146
+ }
147
+ sendJson(res, 200, this.mint(issuer, clientId, old.principal, [scope], false, 'door', old.scopes));
148
+ return;
149
+ }
150
+ sendJson(res, 200, this.mint(issuer, clientId, old.principal, old.scopes, true));
151
+ return;
152
+ }
153
+ oauthError(res, 400, 'unsupported_grant_type', 'grant_type must be authorization_code or refresh_token.');
154
+ }
155
+ mint(issuer, clientId, principal, scopes, withIdToken, kind = 'access',
156
+ /** What the next refresh grants: a door redemption keeps the data scopes. */
157
+ refreshScopes = scopes) {
158
+ const now = Math.floor(Date.now() / 1000);
159
+ const accessToken = this.bearer.issue({ principal, kind, expiresAt: Date.now() + ACCESS_TTL_S * 1000 });
160
+ // Refresh tokens rotate on every use, as a single-page-app issuer's do.
161
+ const refreshToken = this.refresh.issue({
162
+ principal,
163
+ clientId,
164
+ scopes: refreshScopes,
165
+ expiresAt: Date.now() + REFRESH_TTL_MS,
166
+ });
167
+ const idToken = withIdToken
168
+ ? [
169
+ b64url(JSON.stringify({ alg: 'none', typ: 'JWT' })),
170
+ b64url(JSON.stringify({
171
+ iss: issuer,
172
+ sub: principal.id,
173
+ aud: clientId,
174
+ name: principal.displayName,
175
+ email: principal.email,
176
+ iat: now,
177
+ exp: now + ACCESS_TTL_S,
178
+ })),
179
+ '',
180
+ ].join('.')
181
+ : undefined;
182
+ return {
183
+ access_token: accessToken,
184
+ token_type: 'Bearer',
185
+ expires_in: ACCESS_TTL_S,
186
+ refresh_token: refreshToken,
187
+ scope: scopes.join(' '),
188
+ ...(idToken ? { id_token: idToken } : {}),
189
+ };
190
+ }
191
+ }
192
+ //# sourceMappingURL=oidc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oidc.js","sourceRoot":"","sources":["../../src/host/oidc.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * A development stand-in for an external OpenID Connect issuer.\n *\n * When `auth.json` declares `topology: \"external\"`, the app's real issuer\n * cannot be reached from a laptop without registering a redirect URI for\n * every port `pwf dev` picks. The local host therefore plays that issuer, at\n * `<prefix>/oidc`, with just enough of the protocol for the runner's PKCE\n * client (session-protocol §3):\n *\n * GET <prefix>/oidc/.well-known/openid-configuration discovery\n * GET <prefix>/oidc/authorize code + PKCE (S256), no consent screen:\n * the development user is signed in at once\n * POST <prefix>/oidc/token authorization_code, refresh_token\n * (and refresh_token + scope \"<audience>/.default\"\n * for the door token)\n *\n * The declared `clientId`, scopes and audience are kept; only the issuer\n * address changes. Tokens are opaque; the ID token is an UNSIGNED JWT\n * (`alg: none`), which the runner decodes and does not verify. No real\n * issuer's keys are ever involved, and nothing here is safe to expose beyond\n * the developer's own machine.\n */\n\nimport { createHash } from 'node:crypto'\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport { readForm, redirect, sendJson, HostError } from './http.js'\nimport type { AuthDeclaration } from './definitions.js'\nimport { TokenStore, type Principal } from './tokens.js'\n\n/** What a bearer credential stands for, whichever door minted it. */\nexport interface Grant {\n readonly principal: Principal\n readonly expiresAt: number\n readonly kind: 'session' | 'access' | 'door'\n}\n\ninterface CodeGrant {\n readonly principal: Principal\n readonly clientId: string\n readonly redirectUri: string\n readonly challenge: string\n readonly scopes: readonly string[]\n readonly expiresAt: number\n}\n\ninterface RefreshGrant {\n readonly principal: Principal\n readonly clientId: string\n readonly scopes: readonly string[]\n readonly expiresAt: number\n}\n\nexport const ACCESS_TTL_S = 3600\nconst CODE_TTL_MS = 60_000\nconst REFRESH_TTL_MS = 24 * 3600_000\n\nconst b64url = (v: string | Buffer): string => Buffer.from(v).toString('base64url')\n\nfunction oauthError(res: ServerResponse, status: number, error: string, description: string): void {\n sendJson(res, status, { error, error_description: description })\n}\n\nexport class DevIssuer {\n private readonly codes = new TokenStore<CodeGrant>()\n private readonly refresh = new TokenStore<RefreshGrant>()\n\n constructor(\n private readonly bearer: TokenStore<Grant>,\n private readonly user: Principal\n ) {}\n\n reset(): void {\n this.codes.clear()\n this.refresh.clear()\n }\n\n /** `<origin><prefix>/oidc` — the `iss` this issuer speaks for. */\n static issuerUrl(origin: string, prefix: string): string {\n return `${origin.replace(/\\/+$/, '')}${prefix}/oidc`\n }\n\n discovery(res: ServerResponse, issuer: string): void {\n sendJson(res, 200, {\n issuer,\n authorization_endpoint: `${issuer}/authorize`,\n token_endpoint: `${issuer}/token`,\n response_types_supported: ['code'],\n grant_types_supported: ['authorization_code', 'refresh_token'],\n code_challenge_methods_supported: ['S256'],\n subject_types_supported: ['public'],\n id_token_signing_alg_values_supported: ['none'],\n token_endpoint_auth_methods_supported: ['none'],\n })\n }\n\n authorize(req: IncomingMessage, res: ServerResponse, url: URL, auth: AuthDeclaration): void {\n const q = url.searchParams\n const declared = auth.issuer\n if (!declared || q.get('client_id') !== declared.clientId) {\n throw new HostError(400, 'UNKNOWN_CLIENT', 'client_id does not match the app declaration.')\n }\n let redirectUri: URL\n try {\n redirectUri = new URL(q.get('redirect_uri') ?? '')\n } catch {\n throw new HostError(400, 'INVALID_REDIRECT_URI', 'redirect_uri must be an absolute URL.')\n }\n // The app is served by the same development server; anything else is not this app.\n if (!/^https?:$/.test(redirectUri.protocol) || redirectUri.host !== req.headers.host) {\n throw new HostError(400, 'INVALID_REDIRECT_URI', 'redirect_uri must be on this development server.')\n }\n const state = q.get('state')\n const back = (params: Record<string, string>): void => {\n const target = new URL(redirectUri)\n for (const [k, v] of Object.entries(params)) target.searchParams.set(k, v)\n if (state !== null) target.searchParams.set('state', state)\n redirect(res, target.toString())\n }\n if (q.get('response_type') !== 'code') {\n back({ error: 'unsupported_response_type', error_description: 'Only response_type=code is supported.' })\n return\n }\n const challenge = q.get('code_challenge')\n if (!challenge || q.get('code_challenge_method') !== 'S256') {\n back({ error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required.' })\n return\n }\n const code = this.codes.issue({\n principal: this.user,\n clientId: declared.clientId,\n redirectUri: redirectUri.toString(),\n challenge,\n scopes: (q.get('scope') ?? '').split(' ').filter(Boolean),\n expiresAt: Date.now() + CODE_TTL_MS,\n })\n back({ code })\n }\n\n async token(req: IncomingMessage, res: ServerResponse, issuer: string, auth: AuthDeclaration): Promise<void> {\n const form = await readForm(req)\n const clientId = auth.issuer?.clientId\n if (!clientId || form.get('client_id') !== clientId) {\n oauthError(res, 401, 'invalid_client', 'client_id does not match the app declaration.')\n return\n }\n const grantType = form.get('grant_type')\n if (grantType === 'authorization_code') {\n const code = this.codes.take(form.get('code') ?? undefined)\n if (!code || code.clientId !== clientId) {\n oauthError(res, 400, 'invalid_grant', 'The code is unknown, used or expired.')\n return\n }\n if (form.get('redirect_uri') !== code.redirectUri) {\n oauthError(res, 400, 'invalid_grant', 'redirect_uri differs from the one the code was issued for.')\n return\n }\n const verifier = form.get('code_verifier') ?? ''\n if (b64url(createHash('sha256').update(verifier).digest()) !== code.challenge) {\n oauthError(res, 400, 'invalid_grant', 'code_verifier does not match the code_challenge.')\n return\n }\n sendJson(res, 200, this.mint(issuer, clientId, code.principal, code.scopes, true))\n return\n }\n if (grantType === 'refresh_token') {\n const old = this.refresh.take(form.get('refresh_token') ?? undefined)\n if (!old || old.clientId !== clientId) {\n oauthError(res, 400, 'invalid_grant', 'The refresh token is unknown, used or expired.')\n return\n }\n const scope = form.get('scope')\n if (scope) {\n const audience = auth.issuer?.audience?.replace(/\\/+$/, '')\n if (!audience || scope !== `${audience}/.default`) {\n oauthError(res, 400, 'invalid_scope', 'Only the app audience scope \"<audience>/.default\" can be redeemed.')\n return\n }\n sendJson(res, 200, this.mint(issuer, clientId, old.principal, [scope], false, 'door', old.scopes))\n return\n }\n sendJson(res, 200, this.mint(issuer, clientId, old.principal, old.scopes, true))\n return\n }\n oauthError(res, 400, 'unsupported_grant_type', 'grant_type must be authorization_code or refresh_token.')\n }\n\n private mint(\n issuer: string,\n clientId: string,\n principal: Principal,\n scopes: readonly string[],\n withIdToken: boolean,\n kind: Grant['kind'] = 'access',\n /** What the next refresh grants: a door redemption keeps the data scopes. */\n refreshScopes: readonly string[] = scopes\n ): Record<string, unknown> {\n const now = Math.floor(Date.now() / 1000)\n const accessToken = this.bearer.issue({ principal, kind, expiresAt: Date.now() + ACCESS_TTL_S * 1000 })\n // Refresh tokens rotate on every use, as a single-page-app issuer's do.\n const refreshToken = this.refresh.issue({\n principal,\n clientId,\n scopes: refreshScopes,\n expiresAt: Date.now() + REFRESH_TTL_MS,\n })\n const idToken = withIdToken\n ? [\n b64url(JSON.stringify({ alg: 'none', typ: 'JWT' })),\n b64url(\n JSON.stringify({\n iss: issuer,\n sub: principal.id,\n aud: clientId,\n name: principal.displayName,\n email: principal.email,\n iat: now,\n exp: now + ACCESS_TTL_S,\n })\n ),\n '',\n ].join('.')\n : undefined\n return {\n access_token: accessToken,\n token_type: 'Bearer',\n expires_in: ACCESS_TTL_S,\n refresh_token: refreshToken,\n scope: scopes.join(' '),\n ...(idToken ? { id_token: idToken } : {}),\n }\n }\n}\n"]}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The data protocol's query language (data-protocol §3), applied by the host.
3
+ *
4
+ * The runtime applies the same query to the rows it receives, so a server
5
+ * need not filter to be correct. The host filters anyway — the HTTP connector
6
+ * sends `filter`, `sort`, `offset` and `limit`, and a paged list is only
7
+ * honest when the page is cut after filtering. The semantics follow the
8
+ * runtime's own applier: loose equality, number-looking strings compare as
9
+ * numbers, `contains`/`startsWith` ignore case, a leaf with no value matches.
10
+ */
11
+ export type Row = Record<string, unknown>;
12
+ export interface Filter {
13
+ readonly field?: string;
14
+ readonly operator?: string;
15
+ readonly value?: unknown;
16
+ readonly and?: readonly Filter[];
17
+ readonly or?: readonly Filter[];
18
+ }
19
+ export interface SortField {
20
+ readonly field: string;
21
+ readonly direction: 'asc' | 'desc';
22
+ }
23
+ export declare function matches(row: Row, filter: Filter): boolean;
24
+ export declare function sortRows<T extends Row>(rows: readonly T[], sort: readonly SortField[]): T[];
25
+ /** `sort=<field>:<direction>,…` as the HTTP connector writes it. */
26
+ export declare function parseSort(value: string | null): SortField[];
27
+ /** `filter=<json>`; `undefined` when absent. Throws on JSON that is not a filter object. */
28
+ export declare function parseFilter(value: string | null): Filter | undefined;
29
+ export interface AggregateResult {
30
+ readonly fn: 'count' | 'sum' | 'avg';
31
+ readonly field?: string;
32
+ readonly value: number | null;
33
+ readonly count: number;
34
+ }
35
+ export declare function aggregate(rows: readonly Row[], fn: 'count' | 'sum' | 'avg', field: string | undefined): AggregateResult;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The data protocol's query language (data-protocol §3), applied by the host.
3
+ *
4
+ * The runtime applies the same query to the rows it receives, so a server
5
+ * need not filter to be correct. The host filters anyway — the HTTP connector
6
+ * sends `filter`, `sort`, `offset` and `limit`, and a paged list is only
7
+ * honest when the page is cut after filtering. The semantics follow the
8
+ * runtime's own applier: loose equality, number-looking strings compare as
9
+ * numbers, `contains`/`startsWith` ignore case, a leaf with no value matches.
10
+ */
11
+ function readField(row, field) {
12
+ if (field in row)
13
+ return row[field];
14
+ let cur = row;
15
+ for (const part of field.split('.')) {
16
+ if (cur === null || cur === undefined || typeof cur !== 'object')
17
+ return undefined;
18
+ cur = cur[part];
19
+ }
20
+ return cur;
21
+ }
22
+ const NUMERIC = /^\s*[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?\s*$/i;
23
+ function numeric(v) {
24
+ if (typeof v === 'number')
25
+ return Number.isFinite(v) ? v : undefined;
26
+ if (typeof v === 'boolean')
27
+ return v ? 1 : 0;
28
+ if (typeof v !== 'string' || !NUMERIC.test(v))
29
+ return undefined;
30
+ return Number(v);
31
+ }
32
+ function looseEq(a, b) {
33
+ if (a === b)
34
+ return true;
35
+ if (a === null || a === undefined || b === null || b === undefined)
36
+ return false;
37
+ if (typeof a === 'object' || typeof b === 'object')
38
+ return false;
39
+ return String(a) === String(b);
40
+ }
41
+ /** Numbers (and number-looking strings) first and numerically; the rest as text. */
42
+ function compare(a, b) {
43
+ if (a === null || a === undefined || b === null || b === undefined)
44
+ return undefined;
45
+ const na = numeric(a);
46
+ const nb = numeric(b);
47
+ if (na !== undefined && nb !== undefined)
48
+ return na - nb;
49
+ if (na !== undefined)
50
+ return -1;
51
+ if (nb !== undefined)
52
+ return 1;
53
+ const sa = String(a);
54
+ const sb = String(b);
55
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
56
+ }
57
+ function leaf(actual, operator, expected) {
58
+ switch (operator) {
59
+ case 'eq':
60
+ return looseEq(actual, expected);
61
+ case 'neq':
62
+ return !looseEq(actual, expected);
63
+ case 'gt':
64
+ case 'gte':
65
+ case 'lt':
66
+ case 'lte': {
67
+ const c = compare(actual, expected);
68
+ if (c === undefined)
69
+ return false;
70
+ return operator === 'gt' ? c > 0 : operator === 'gte' ? c >= 0 : operator === 'lt' ? c < 0 : c <= 0;
71
+ }
72
+ case 'in':
73
+ return (Array.isArray(expected) ? expected : [expected]).some((e) => looseEq(actual, e));
74
+ case 'contains':
75
+ if (Array.isArray(actual))
76
+ return actual.some((x) => looseEq(x, expected));
77
+ return actual !== null && actual !== undefined && String(actual).toLowerCase().includes(String(expected).toLowerCase());
78
+ case 'startsWith':
79
+ return actual !== null && actual !== undefined && String(actual).toLowerCase().startsWith(String(expected).toLowerCase());
80
+ default:
81
+ return false;
82
+ }
83
+ }
84
+ export function matches(row, filter) {
85
+ const hasLeaf = typeof filter.field === 'string' && filter.field !== '' && typeof filter.operator === 'string' && filter.value !== undefined;
86
+ const ok = hasLeaf ? leaf(readField(row, filter.field), filter.operator, filter.value) : true;
87
+ const andOk = filter.and && filter.and.length > 0 ? filter.and.every((f) => matches(row, f)) : true;
88
+ const orOk = filter.or && filter.or.length > 0 ? filter.or.some((f) => matches(row, f)) : true;
89
+ return ok && andOk && orOk;
90
+ }
91
+ export function sortRows(rows, sort) {
92
+ return rows
93
+ .map((item, index) => ({ item, index }))
94
+ .sort((a, b) => {
95
+ for (const s of sort) {
96
+ const va = readField(a.item, s.field);
97
+ const vb = readField(b.item, s.field);
98
+ const na = va === null || va === undefined;
99
+ const nb = vb === null || vb === undefined;
100
+ if (na && nb)
101
+ continue;
102
+ if (na)
103
+ return 1;
104
+ if (nb)
105
+ return -1;
106
+ const c = compare(va, vb) ?? 0;
107
+ if (c !== 0)
108
+ return s.direction === 'desc' ? -c : c;
109
+ }
110
+ return a.index - b.index;
111
+ })
112
+ .map((x) => x.item);
113
+ }
114
+ /** `sort=<field>:<direction>,…` as the HTTP connector writes it. */
115
+ export function parseSort(value) {
116
+ if (!value)
117
+ return [];
118
+ return value
119
+ .split(',')
120
+ .map((part) => part.trim())
121
+ .filter(Boolean)
122
+ .flatMap((part) => {
123
+ const at = part.lastIndexOf(':');
124
+ const field = (at === -1 ? part : part.slice(0, at)).trim();
125
+ const dir = at === -1 ? 'asc' : part.slice(at + 1).trim().toLowerCase();
126
+ return field ? [{ field, direction: dir === 'desc' ? 'desc' : 'asc' }] : [];
127
+ });
128
+ }
129
+ /** `filter=<json>`; `undefined` when absent. Throws on JSON that is not a filter object. */
130
+ export function parseFilter(value) {
131
+ if (value === null || value.trim() === '')
132
+ return undefined;
133
+ const parsed = JSON.parse(value);
134
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
135
+ throw new Error('filter must be a JSON object');
136
+ return parsed;
137
+ }
138
+ export function aggregate(rows, fn, field) {
139
+ const base = { fn, ...(field ? { field } : {}), count: rows.length };
140
+ if (fn === 'count')
141
+ return { ...base, value: rows.length };
142
+ if (!field)
143
+ return { ...base, value: null };
144
+ const nums = [];
145
+ for (const r of rows) {
146
+ const v = readField(r, field);
147
+ if (v === null || v === undefined || v === '')
148
+ continue;
149
+ const n = typeof v === 'number' ? v : Number(v);
150
+ if (Number.isFinite(n))
151
+ nums.push(n);
152
+ }
153
+ if (nums.length === 0)
154
+ return { ...base, value: null };
155
+ const sum = nums.reduce((a, b) => a + b, 0);
156
+ return { ...base, value: fn === 'sum' ? sum : sum / nums.length };
157
+ }
158
+ //# sourceMappingURL=query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.js","sourceRoot":"","sources":["../../src/host/query.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The data protocol's query language (data-protocol §3), applied by the host.\n *\n * The runtime applies the same query to the rows it receives, so a server\n * need not filter to be correct. The host filters anyway — the HTTP connector\n * sends `filter`, `sort`, `offset` and `limit`, and a paged list is only\n * honest when the page is cut after filtering. The semantics follow the\n * runtime's own applier: loose equality, number-looking strings compare as\n * numbers, `contains`/`startsWith` ignore case, a leaf with no value matches.\n */\n\nexport type Row = Record<string, unknown>\n\nexport interface Filter {\n readonly field?: string\n readonly operator?: string\n readonly value?: unknown\n readonly and?: readonly Filter[]\n readonly or?: readonly Filter[]\n}\n\nexport interface SortField {\n readonly field: string\n readonly direction: 'asc' | 'desc'\n}\n\nfunction readField(row: Row, field: string): unknown {\n if (field in row) return row[field]\n let cur: unknown = row\n for (const part of field.split('.')) {\n if (cur === null || cur === undefined || typeof cur !== 'object') return undefined\n cur = (cur as Row)[part]\n }\n return cur\n}\n\nconst NUMERIC = /^\\s*[-+]?(\\d+\\.?\\d*|\\.\\d+)(e[-+]?\\d+)?\\s*$/i\n\nfunction numeric(v: unknown): number | undefined {\n if (typeof v === 'number') return Number.isFinite(v) ? v : undefined\n if (typeof v === 'boolean') return v ? 1 : 0\n if (typeof v !== 'string' || !NUMERIC.test(v)) return undefined\n return Number(v)\n}\n\nfunction looseEq(a: unknown, b: unknown): boolean {\n if (a === b) return true\n if (a === null || a === undefined || b === null || b === undefined) return false\n if (typeof a === 'object' || typeof b === 'object') return false\n return String(a) === String(b)\n}\n\n/** Numbers (and number-looking strings) first and numerically; the rest as text. */\nfunction compare(a: unknown, b: unknown): number | undefined {\n if (a === null || a === undefined || b === null || b === undefined) return undefined\n const na = numeric(a)\n const nb = numeric(b)\n if (na !== undefined && nb !== undefined) return na - nb\n if (na !== undefined) return -1\n if (nb !== undefined) return 1\n const sa = String(a)\n const sb = String(b)\n return sa < sb ? -1 : sa > sb ? 1 : 0\n}\n\nfunction leaf(actual: unknown, operator: string, expected: unknown): boolean {\n switch (operator) {\n case 'eq':\n return looseEq(actual, expected)\n case 'neq':\n return !looseEq(actual, expected)\n case 'gt':\n case 'gte':\n case 'lt':\n case 'lte': {\n const c = compare(actual, expected)\n if (c === undefined) return false\n return operator === 'gt' ? c > 0 : operator === 'gte' ? c >= 0 : operator === 'lt' ? c < 0 : c <= 0\n }\n case 'in':\n return (Array.isArray(expected) ? expected : [expected]).some((e) => looseEq(actual, e))\n case 'contains':\n if (Array.isArray(actual)) return actual.some((x) => looseEq(x, expected))\n return actual !== null && actual !== undefined && String(actual).toLowerCase().includes(String(expected).toLowerCase())\n case 'startsWith':\n return actual !== null && actual !== undefined && String(actual).toLowerCase().startsWith(String(expected).toLowerCase())\n default:\n return false\n }\n}\n\nexport function matches(row: Row, filter: Filter): boolean {\n const hasLeaf = typeof filter.field === 'string' && filter.field !== '' && typeof filter.operator === 'string' && filter.value !== undefined\n const ok = hasLeaf ? leaf(readField(row, filter.field as string), filter.operator as string, filter.value) : true\n const andOk = filter.and && filter.and.length > 0 ? filter.and.every((f) => matches(row, f)) : true\n const orOk = filter.or && filter.or.length > 0 ? filter.or.some((f) => matches(row, f)) : true\n return ok && andOk && orOk\n}\n\nexport function sortRows<T extends Row>(rows: readonly T[], sort: readonly SortField[]): T[] {\n return rows\n .map((item, index) => ({ item, index }))\n .sort((a, b) => {\n for (const s of sort) {\n const va = readField(a.item, s.field)\n const vb = readField(b.item, s.field)\n const na = va === null || va === undefined\n const nb = vb === null || vb === undefined\n if (na && nb) continue\n if (na) return 1\n if (nb) return -1\n const c = compare(va, vb) ?? 0\n if (c !== 0) return s.direction === 'desc' ? -c : c\n }\n return a.index - b.index\n })\n .map((x) => x.item)\n}\n\n/** `sort=<field>:<direction>,…` as the HTTP connector writes it. */\nexport function parseSort(value: string | null): SortField[] {\n if (!value) return []\n return value\n .split(',')\n .map((part) => part.trim())\n .filter(Boolean)\n .flatMap((part) => {\n const at = part.lastIndexOf(':')\n const field = (at === -1 ? part : part.slice(0, at)).trim()\n const dir = at === -1 ? 'asc' : part.slice(at + 1).trim().toLowerCase()\n return field ? [{ field, direction: dir === 'desc' ? ('desc' as const) : ('asc' as const) }] : []\n })\n}\n\n/** `filter=<json>`; `undefined` when absent. Throws on JSON that is not a filter object. */\nexport function parseFilter(value: string | null): Filter | undefined {\n if (value === null || value.trim() === '') return undefined\n const parsed = JSON.parse(value) as unknown\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('filter must be a JSON object')\n return parsed as Filter\n}\n\nexport interface AggregateResult {\n readonly fn: 'count' | 'sum' | 'avg'\n readonly field?: string\n readonly value: number | null\n readonly count: number\n}\n\nexport function aggregate(rows: readonly Row[], fn: 'count' | 'sum' | 'avg', field: string | undefined): AggregateResult {\n const base = { fn, ...(field ? { field } : {}), count: rows.length }\n if (fn === 'count') return { ...base, value: rows.length }\n if (!field) return { ...base, value: null }\n const nums: number[] = []\n for (const r of rows) {\n const v = readField(r, field)\n if (v === null || v === undefined || v === '') continue\n const n = typeof v === 'number' ? v : Number(v)\n if (Number.isFinite(n)) nums.push(n)\n }\n if (nums.length === 0) return { ...base, value: null }\n const sum = nums.reduce((a, b) => a + b, 0)\n return { ...base, value: fn === 'sum' ? sum : sum / nums.length }\n}\n"]}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pages, pointed at the local host's records.
3
+ *
4
+ * A receipt declares its data as `entity` sources; with no `connector` the
5
+ * runner would look for a connection named `graph-lists` that a local app does
6
+ * not have. A publisher that hosts the app's records MAY rewrite such a
7
+ * source into a `rest` source against its record endpoints (data-protocol
8
+ * §2.2), and that is what this does for every source whose `entityType` the
9
+ * app declares:
10
+ *
11
+ * list → { kind: "rest", method: "GET", endpoint: "<base>/records/<T>",
12
+ * authProviderRef: "<base>", returns: "data", query? }
13
+ * aggregate → the same, at "<base>/records/<T>/count" (a plain count) or
14
+ * "<base>/records/<T>/aggregate?fn=&field=&filter=", so the
15
+ * bound value is `{ fn, field?, value, count }` exactly as an
16
+ * entity aggregate binds it.
17
+ *
18
+ * `authProviderRef` equal to the base is what makes the runner attach the
19
+ * visitor's session as a bearer token (session-protocol §7). Sources that name
20
+ * a connector, or a type the app does not declare, are left as written.
21
+ */
22
+ type Json = Record<string, unknown>;
23
+ /** A copy of `receipt` whose declared entity sources read from `base`. Non-mutating. */
24
+ export declare function rewriteEntitySources(receipt: Json, base: string, declared: ReadonlySet<string>): Json;
25
+ export {};
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Pages, pointed at the local host's records.
3
+ *
4
+ * A receipt declares its data as `entity` sources; with no `connector` the
5
+ * runner would look for a connection named `graph-lists` that a local app does
6
+ * not have. A publisher that hosts the app's records MAY rewrite such a
7
+ * source into a `rest` source against its record endpoints (data-protocol
8
+ * §2.2), and that is what this does for every source whose `entityType` the
9
+ * app declares:
10
+ *
11
+ * list → { kind: "rest", method: "GET", endpoint: "<base>/records/<T>",
12
+ * authProviderRef: "<base>", returns: "data", query? }
13
+ * aggregate → the same, at "<base>/records/<T>/count" (a plain count) or
14
+ * "<base>/records/<T>/aggregate?fn=&field=&filter=", so the
15
+ * bound value is `{ fn, field?, value, count }` exactly as an
16
+ * entity aggregate binds it.
17
+ *
18
+ * `authProviderRef` equal to the base is what makes the runner attach the
19
+ * visitor's session as a bearer token (session-protocol §7). Sources that name
20
+ * a connector, or a type the app does not declare, are left as written.
21
+ */
22
+ const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
23
+ const isPersistence = (c) => isObject(c) && (c.capability === 'persistence' || c.capability === 'platform/persistence');
24
+ function rewriteSource(source, base, declared) {
25
+ if (!isObject(source) || source.kind !== 'entity')
26
+ return source;
27
+ const type = source.entityType;
28
+ if (typeof type !== 'string' || !declared.has(type) || (typeof source.connector === 'string' && source.connector)) {
29
+ return source;
30
+ }
31
+ const { entityType: _t, connector: _c, aggregate, ...rest } = source;
32
+ const collection = `${base}/records/${encodeURIComponent(type)}`;
33
+ let endpoint = collection;
34
+ if (isObject(aggregate)) {
35
+ const fn = typeof aggregate.fn === 'string' ? aggregate.fn : 'count';
36
+ const field = typeof aggregate.field === 'string' && aggregate.field ? aggregate.field : undefined;
37
+ const params = new URLSearchParams();
38
+ if (fn !== 'count' || field) {
39
+ params.set('fn', fn);
40
+ if (field)
41
+ params.set('field', field);
42
+ }
43
+ if (isObject(aggregate.filter))
44
+ params.set('filter', JSON.stringify(aggregate.filter));
45
+ const route = fn === 'count' && !field ? 'count' : 'aggregate';
46
+ const qs = params.toString();
47
+ endpoint = `${collection}/${route}${qs ? `?${qs}` : ''}`;
48
+ }
49
+ return { ...rest, kind: 'rest', method: 'GET', endpoint, authProviderRef: base, returns: 'data' };
50
+ }
51
+ /** A copy of `receipt` whose declared entity sources read from `base`. Non-mutating. */
52
+ export function rewriteEntitySources(receipt, base, declared) {
53
+ const caps = receipt.capabilities;
54
+ if (!Array.isArray(caps))
55
+ return receipt;
56
+ return {
57
+ ...receipt,
58
+ capabilities: caps.map((cap) => {
59
+ if (!isPersistence(cap))
60
+ return cap;
61
+ const c = cap;
62
+ const config = isObject(c.config) ? c.config : undefined;
63
+ if (!config || !Array.isArray(config.dataSources))
64
+ return cap;
65
+ return {
66
+ ...c,
67
+ config: { ...config, dataSources: config.dataSources.map((s) => rewriteSource(s, base, declared)) },
68
+ };
69
+ }),
70
+ };
71
+ }
72
+ //# sourceMappingURL=receipts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"receipts.js","sourceRoot":"","sources":["../../src/host/receipts.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pages, pointed at the local host's records.\n *\n * A receipt declares its data as `entity` sources; with no `connector` the\n * runner would look for a connection named `graph-lists` that a local app does\n * not have. A publisher that hosts the app's records MAY rewrite such a\n * source into a `rest` source against its record endpoints (data-protocol\n * §2.2), and that is what this does for every source whose `entityType` the\n * app declares:\n *\n * list → { kind: \"rest\", method: \"GET\", endpoint: \"<base>/records/<T>\",\n * authProviderRef: \"<base>\", returns: \"data\", query? }\n * aggregate → the same, at \"<base>/records/<T>/count\" (a plain count) or\n * \"<base>/records/<T>/aggregate?fn=&field=&filter=\", so the\n * bound value is `{ fn, field?, value, count }` exactly as an\n * entity aggregate binds it.\n *\n * `authProviderRef` equal to the base is what makes the runner attach the\n * visitor's session as a bearer token (session-protocol §7). Sources that name\n * a connector, or a type the app does not declare, are left as written.\n */\n\ntype Json = Record<string, unknown>\n\nconst isObject = (v: unknown): v is Json => Boolean(v) && typeof v === 'object' && !Array.isArray(v)\n\nconst isPersistence = (c: unknown): boolean =>\n isObject(c) && (c.capability === 'persistence' || c.capability === 'platform/persistence')\n\nfunction rewriteSource(source: unknown, base: string, declared: ReadonlySet<string>): unknown {\n if (!isObject(source) || source.kind !== 'entity') return source\n const type = source.entityType\n if (typeof type !== 'string' || !declared.has(type) || (typeof source.connector === 'string' && source.connector)) {\n return source\n }\n const { entityType: _t, connector: _c, aggregate, ...rest } = source\n const collection = `${base}/records/${encodeURIComponent(type)}`\n let endpoint = collection\n if (isObject(aggregate)) {\n const fn = typeof aggregate.fn === 'string' ? aggregate.fn : 'count'\n const field = typeof aggregate.field === 'string' && aggregate.field ? aggregate.field : undefined\n const params = new URLSearchParams()\n if (fn !== 'count' || field) {\n params.set('fn', fn)\n if (field) params.set('field', field)\n }\n if (isObject(aggregate.filter)) params.set('filter', JSON.stringify(aggregate.filter))\n const route = fn === 'count' && !field ? 'count' : 'aggregate'\n const qs = params.toString()\n endpoint = `${collection}/${route}${qs ? `?${qs}` : ''}`\n }\n return { ...rest, kind: 'rest', method: 'GET', endpoint, authProviderRef: base, returns: 'data' }\n}\n\n/** A copy of `receipt` whose declared entity sources read from `base`. Non-mutating. */\nexport function rewriteEntitySources(receipt: Json, base: string, declared: ReadonlySet<string>): Json {\n const caps = receipt.capabilities\n if (!Array.isArray(caps)) return receipt\n return {\n ...receipt,\n capabilities: caps.map((cap) => {\n if (!isPersistence(cap)) return cap\n const c = cap as Json\n const config = isObject(c.config) ? c.config : undefined\n if (!config || !Array.isArray(config.dataSources)) return cap\n return {\n ...c,\n config: { ...config, dataSources: config.dataSources.map((s) => rewriteSource(s, base, declared)) },\n }\n }),\n }\n}\n"]}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The app's own records, in memory (data-protocol §5).
3
+ *
4
+ * One collection per entity type declared in `app/definitions/entities.json`,
5
+ * seeded on first use from `app/data/` (see `readSeed`). A record is
6
+ * `{ …fields, id, entityType, createdAt, updatedAt }`, fields spread at the
7
+ * top level like the reference server's.
8
+ *
9
+ * ACCESS, read off the declarations — a deliberately small version of what a
10
+ * hosted session server decides:
11
+ *
12
+ * - `auth.json` `required: true`: every data request needs a session
13
+ * (`401 SIGN_IN_REQUIRED`).
14
+ * - A collection is OWNER-SCOPED when an `access.rules[]` entry names it and
15
+ * is not a public read grant (subject `public`/`anyone`/`visitors`/…, a
16
+ * rule that grants reading and does not say "own"), when the entity
17
+ * declares an owner field (`owner`, `ownerId`, `userId`, `createdBy`, …),
18
+ * or when the entity carries `"scope": "owner"`. An owner-scoped collection
19
+ * needs a session, lists only the caller's rows, and answers another
20
+ * visitor's row as `404`.
21
+ * - Every other collection is shared by the whole app: anyone may read and
22
+ * write it.
23
+ */
24
+ import { type AuthDeclaration, type EntityDeclaration } from './definitions.js';
25
+ import { type AggregateResult, type Row } from './query.js';
26
+ import type { Principal } from './tokens.js';
27
+ export type Scope = 'project' | 'owner';
28
+ export declare function scopeOf(entity: EntityDeclaration, auth: AuthDeclaration | undefined): Scope;
29
+ export interface Caller {
30
+ readonly principal: Principal | undefined;
31
+ }
32
+ export interface ListAnswer {
33
+ readonly data: Row[];
34
+ readonly meta: {
35
+ readonly total: number;
36
+ readonly scope: Scope;
37
+ readonly timestamp: string;
38
+ };
39
+ }
40
+ export interface AggregateAnswer {
41
+ readonly data: AggregateResult;
42
+ readonly meta: {
43
+ readonly scope: Scope;
44
+ readonly timestamp: string;
45
+ };
46
+ }
47
+ /** The data the request is about: which collection, under which declaration, for whom. */
48
+ export interface Target {
49
+ readonly entity: EntityDeclaration;
50
+ readonly auth: AuthDeclaration | undefined;
51
+ readonly caller: Caller;
52
+ }
53
+ export declare class RecordStore {
54
+ private readonly root;
55
+ private readonly devUser;
56
+ private readonly collections;
57
+ private clock;
58
+ constructor(root: string, devUser: Principal);
59
+ reset(): void;
60
+ private collection;
61
+ /** Refuses a caller the declarations do not let in; answers the collection's scope. */
62
+ private guard;
63
+ /** The rows this caller may see, newest change first, after `filter`. */
64
+ private visible;
65
+ list(target: Target, params: URLSearchParams): ListAnswer;
66
+ /** `/count` (`plainCount`) or `/aggregate?fn&field`. */
67
+ aggregate(target: Target, params: URLSearchParams, plainCount: boolean): AggregateAnswer;
68
+ private find;
69
+ read(target: Target, id: string): Row;
70
+ create(target: Target, body: Record<string, unknown>): Row;
71
+ update(target: Target, id: string, body: Record<string, unknown>): Row;
72
+ remove(target: Target, id: string): void;
73
+ }