@selvajs/server 0.2.0-beta.2 → 0.2.0-beta.4

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 (155) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -0
  3. package/dist/compute/client-cache.d.ts +12 -0
  4. package/dist/compute/client-cache.d.ts.map +1 -1
  5. package/dist/compute/client-cache.js +18 -3
  6. package/dist/compute/client-cache.js.map +1 -1
  7. package/dist/compute/definition-byte-cache.d.ts +85 -0
  8. package/dist/compute/definition-byte-cache.d.ts.map +1 -0
  9. package/dist/compute/definition-byte-cache.js +110 -0
  10. package/dist/compute/definition-byte-cache.js.map +1 -0
  11. package/dist/compute/index.d.ts +7 -2
  12. package/dist/compute/index.d.ts.map +1 -1
  13. package/dist/compute/index.js +6 -1
  14. package/dist/compute/index.js.map +1 -1
  15. package/dist/compute/limits.d.ts +52 -0
  16. package/dist/compute/limits.d.ts.map +1 -1
  17. package/dist/compute/limits.js +33 -1
  18. package/dist/compute/limits.js.map +1 -1
  19. package/dist/compute/memory-solve-cache.d.ts +58 -0
  20. package/dist/compute/memory-solve-cache.d.ts.map +1 -0
  21. package/dist/compute/memory-solve-cache.js +0 -0
  22. package/dist/compute/memory-solve-cache.js.map +1 -0
  23. package/dist/compute/safe-url.d.ts.map +1 -1
  24. package/dist/compute/safe-url.js +16 -0
  25. package/dist/compute/safe-url.js.map +1 -1
  26. package/dist/compute/solve-cache-envelope.d.ts +53 -0
  27. package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
  28. package/dist/compute/solve-cache-envelope.js +66 -0
  29. package/dist/compute/solve-cache-envelope.js.map +1 -0
  30. package/dist/compute/solve-cache-key.d.ts +65 -0
  31. package/dist/compute/solve-cache-key.d.ts.map +1 -0
  32. package/dist/compute/solve-cache-key.js +50 -0
  33. package/dist/compute/solve-cache-key.js.map +1 -0
  34. package/dist/compute/solve-cache-single-flight.d.ts +42 -0
  35. package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
  36. package/dist/compute/solve-cache-single-flight.js +50 -0
  37. package/dist/compute/solve-cache-single-flight.js.map +1 -0
  38. package/dist/compute/solve-pipeline.d.ts +71 -8
  39. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  40. package/dist/compute/solve-pipeline.js +180 -8
  41. package/dist/compute/solve-pipeline.js.map +1 -1
  42. package/dist/definitions/definition-service.d.ts +1 -1
  43. package/dist/definitions/definition-service.d.ts.map +1 -1
  44. package/dist/definitions/definition-service.js +8 -4
  45. package/dist/definitions/definition-service.js.map +1 -1
  46. package/package.json +14 -18
  47. package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
  48. package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
  49. package/dist/access/__tests__/project-access-input.test.js +0 -146
  50. package/dist/access/__tests__/project-access-input.test.js.map +0 -1
  51. package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
  52. package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
  53. package/dist/compute/__tests__/client-cache.test.js +0 -200
  54. package/dist/compute/__tests__/client-cache.test.js.map +0 -1
  55. package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
  56. package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
  57. package/dist/compute/__tests__/rate-limit.test.js +0 -98
  58. package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
  59. package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
  60. package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
  61. package/dist/compute/__tests__/remote-definition.test.js +0 -114
  62. package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
  63. package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
  64. package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
  65. package/dist/compute/__tests__/safe-url.test.js +0 -71
  66. package/dist/compute/__tests__/safe-url.test.js.map +0 -1
  67. package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
  68. package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
  69. package/dist/compute/__tests__/solve-pipeline.test.js +0 -292
  70. package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
  71. package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
  72. package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
  73. package/dist/compute/__tests__/transform-input.test.js +0 -120
  74. package/dist/compute/__tests__/transform-input.test.js.map +0 -1
  75. package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
  76. package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
  77. package/dist/definitions/__tests__/load-for-render.test.js +0 -196
  78. package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
  79. package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
  80. package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
  81. package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
  82. package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
  83. package/dist/http/__tests__/body-size.test.d.ts +0 -2
  84. package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
  85. package/dist/http/__tests__/body-size.test.js +0 -24
  86. package/dist/http/__tests__/body-size.test.js.map +0 -1
  87. package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
  88. package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
  89. package/dist/http/__tests__/route-classifier.test.js +0 -54
  90. package/dist/http/__tests__/route-classifier.test.js.map +0 -1
  91. package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
  92. package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
  93. package/dist/http/__tests__/safe-redirect.test.js +0 -44
  94. package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
  95. package/dist/http/__tests__/security-headers.test.d.ts +0 -2
  96. package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
  97. package/dist/http/__tests__/security-headers.test.js +0 -24
  98. package/dist/http/__tests__/security-headers.test.js.map +0 -1
  99. package/dist/ops/__tests__/semver.test.d.ts +0 -2
  100. package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
  101. package/dist/ops/__tests__/semver.test.js +0 -38
  102. package/dist/ops/__tests__/semver.test.js.map +0 -1
  103. package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
  104. package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
  105. package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
  106. package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
  107. package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
  108. package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
  109. package/dist/tokens/__tests__/token-codec.test.js +0 -50
  110. package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
  111. package/src/access/__tests__/project-access-input.test.ts +0 -175
  112. package/src/access/index.ts +0 -9
  113. package/src/access/project-access-input.ts +0 -145
  114. package/src/compute/__tests__/client-cache.test.ts +0 -224
  115. package/src/compute/__tests__/rate-limit.test.ts +0 -106
  116. package/src/compute/__tests__/remote-definition.test.ts +0 -140
  117. package/src/compute/__tests__/safe-url.test.ts +0 -72
  118. package/src/compute/__tests__/solve-pipeline.test.ts +0 -341
  119. package/src/compute/__tests__/transform-input.test.ts +0 -162
  120. package/src/compute/client-cache.ts +0 -300
  121. package/src/compute/index.ts +0 -48
  122. package/src/compute/limits.ts +0 -159
  123. package/src/compute/rate-limit.ts +0 -110
  124. package/src/compute/remote-definition.ts +0 -149
  125. package/src/compute/safe-url.ts +0 -186
  126. package/src/compute/solve-pipeline.ts +0 -339
  127. package/src/compute/transform-input.ts +0 -66
  128. package/src/definitions/__tests__/load-for-render.test.ts +0 -244
  129. package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
  130. package/src/definitions/definition-service.ts +0 -243
  131. package/src/definitions/index.ts +0 -22
  132. package/src/definitions/load-for-render.ts +0 -272
  133. package/src/definitions/schema-extraction.ts +0 -117
  134. package/src/errors/SentryErrorReporter.ts +0 -88
  135. package/src/errors/index.ts +0 -3
  136. package/src/http/__tests__/body-size.test.ts +0 -27
  137. package/src/http/__tests__/route-classifier.test.ts +0 -60
  138. package/src/http/__tests__/safe-redirect.test.ts +0 -47
  139. package/src/http/__tests__/security-headers.test.ts +0 -29
  140. package/src/http/body-size.ts +0 -24
  141. package/src/http/index.ts +0 -10
  142. package/src/http/redirect.ts +0 -16
  143. package/src/http/route-classifier.ts +0 -74
  144. package/src/http/security-headers.ts +0 -42
  145. package/src/index.ts +0 -27
  146. package/src/ops/__tests__/semver.test.ts +0 -42
  147. package/src/ops/index.ts +0 -3
  148. package/src/ops/semver.ts +0 -60
  149. package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
  150. package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
  151. package/src/providers/create-selva-providers.ts +0 -241
  152. package/src/providers/index.ts +0 -12
  153. package/src/tokens/__tests__/token-codec.test.ts +0 -63
  154. package/src/tokens/index.ts +0 -8
  155. package/src/tokens/token-codec.ts +0 -86
@@ -1,241 +0,0 @@
1
- // Configurable provider wiring (K5) — the reusable core of the app's
2
- // composition root, extracted so any app built on the engine gets the same
3
- // env-driven provider selection, `selva.config.js` override hook, and lazy
4
- // instantiation without copying it.
5
- //
6
- // The provider IMPLEMENTATIONS are not bundled: the caller passes a
7
- // `ProviderRegistry` mapping choice names (the `SELVA_*_PROVIDER` env values)
8
- // to `fromEnv`-style factories. That keeps this package free of dependencies
9
- // on `@selvajs/local-provider` / `@selvajs/supabase-provider` — a consuming
10
- // app registers exactly the providers it ships.
11
- //
12
- // Two wiring modes (unchanged from the app):
13
- //
14
- // Default — providers are picked from env vars (SELVA_AUTH_PROVIDER etc.)
15
- // via the registry. New deployments need only a .env file.
16
- //
17
- // Override — pass `configPath` (e.g. env.SELVA_CONFIG_PATH) pointing at an
18
- // external `selva.config.js` (absolute or CWD-relative). It is loaded
19
- // dynamically at boot and replaces the env-driven wiring entirely. The path
20
- // must resolve to an actual `.js` file — there's no TS compiler at runtime.
21
-
22
- import { pathToFileURL } from 'node:url';
23
- import { resolve as resolvePath } from 'node:path';
24
- import { existsSync } from 'node:fs';
25
- import type {
26
- IAuthProvider,
27
- IDataProvider,
28
- IStorageProvider,
29
- ISolveMetricSink,
30
- SelvaBranding,
31
- SelvaConfig,
32
- SelvaConfigFactory,
33
- SelvaFlags,
34
- TenancyMode
35
- } from '@selvajs/platform';
36
- import { isFlagEnabled, NoopSolveMetricSink } from '@selvajs/platform';
37
- import { readBool, type EnvRecord } from '../compute/limits.js';
38
-
39
- export type ProviderFactory<T> = (env: EnvRecord) => T;
40
-
41
- /**
42
- * The provider implementations available to this deployment, keyed by the
43
- * (lowercase) choice name used in `SELVA_AUTH_PROVIDER` / `SELVA_DATA_PROVIDER`
44
- * / `SELVA_STORAGE_PROVIDER`.
45
- */
46
- export interface ProviderRegistry {
47
- auth: Record<string, ProviderFactory<IAuthProvider>>;
48
- data: Record<string, ProviderFactory<IDataProvider>>;
49
- storage: Record<string, ProviderFactory<IStorageProvider>>;
50
- }
51
-
52
- export interface CreateSelvaProvidersOptions {
53
- registry: ProviderRegistry;
54
- /** Fallback choice per slot when the env var is unset. Default: `'local'`. */
55
- defaults?: Partial<Record<'auth' | 'data' | 'storage', string>>;
56
- /**
57
- * Path to an external `selva.config.js` override (absolute or CWD-relative).
58
- * Pass `env.SELVA_CONFIG_PATH` through; when set, the module's default
59
- * export (a `SelvaConfig` or factory) replaces the env-driven wiring.
60
- */
61
- configPath?: string;
62
- /** Boot-summary sink, called once on first `resolve()`. Default: `console.info`. */
63
- onBoot?: (summary: string) => void;
64
- }
65
-
66
- export interface SelvaProviderRuntime {
67
- /**
68
- * Memoized provider wiring. The first call instantiates providers from env
69
- * (and may throw if required secrets are missing); subsequent calls return
70
- * the cached config. Never call at build time — provider `fromEnv`
71
- * factories validate required secrets.
72
- */
73
- resolve(): SelvaConfig;
74
- tenancy(): TenancyMode;
75
- /** Resolved branding — every field defaulted so the UI never null-checks. */
76
- branding(): Required<SelvaBranding>;
77
- /** Safe flag accessor — omitted flags resolve to false. */
78
- flag(name: keyof SelvaFlags): boolean;
79
- /** Per-solve timing sink; `NoopSolveMetricSink` when the config omits one. */
80
- solveMetricSink(): ISolveMetricSink;
81
- }
82
-
83
- // Every SelvaFlags key, mapped from `SELVA_FLAG_<KEY>`. The `satisfies` +
84
- // exhaustiveness check below make adding a platform flag without listing it
85
- // here a compile error.
86
- const FLAG_KEYS = [
87
- 'ALLOW_CROSS_ORG_PUBLIC',
88
- 'ALLOW_ORG_COMPUTE_OVERRIDE',
89
- 'ALLOW_ORG_CREATION',
90
- 'ENABLE_PLATFORM_PROJECTS',
91
- 'ENABLE_SHARING'
92
- ] as const satisfies readonly (keyof SelvaFlags)[];
93
-
94
- type AllFlagsListed =
95
- Exclude<keyof SelvaFlags, (typeof FLAG_KEYS)[number]> extends never ? true : never;
96
-
97
- const _allFlagsListed: AllFlagsListed = true;
98
-
99
- function pick<T>(
100
- env: EnvRecord,
101
- envVar: string,
102
- slot: Record<string, ProviderFactory<T>>,
103
- fallback: string
104
- ): T {
105
- const choice = (env[envVar] ?? fallback).toLowerCase();
106
- const factory = slot[choice];
107
- if (!factory) {
108
- throw new Error(`Unknown ${envVar}="${choice}". Expected: ${Object.keys(slot).join(' | ')}.`);
109
- }
110
- return factory(env);
111
- }
112
-
113
- function pickTenancy(env: EnvRecord): TenancyMode {
114
- const choice = (env.SELVA_TENANCY ?? 'single').toLowerCase();
115
- if (choice !== 'single' && choice !== 'multi') {
116
- throw new Error(`Unknown SELVA_TENANCY="${choice}". Expected: single | multi.`);
117
- }
118
- return choice;
119
- }
120
-
121
- /**
122
- * Per-solve metric sink. Supabase's data provider carries a `solveMetrics`
123
- * sink built from its own client bundle — reuse it so timings persist
124
- * automatically. Backends without a metrics table leave it undefined, which
125
- * falls back to `NoopSolveMetricSink` in `solveMetricSink()`.
126
- */
127
- function pickSolveMetrics(data: IDataProvider): ISolveMetricSink | undefined {
128
- const candidate = (data as { solveMetrics?: unknown }).solveMetrics;
129
- if (candidate && typeof (candidate as { record?: unknown }).record === 'function') {
130
- return candidate as ISolveMetricSink;
131
- }
132
- return undefined;
133
- }
134
-
135
- function buildDefaultFactory(options: CreateSelvaProvidersOptions): SelvaConfigFactory {
136
- const { registry, defaults } = options;
137
- return (env) => {
138
- const data = pick(env, 'SELVA_DATA_PROVIDER', registry.data, defaults?.data ?? 'local');
139
- return {
140
- tenancy: pickTenancy(env),
141
- flags: Object.fromEntries(
142
- FLAG_KEYS.map((key) => [key, readBool(env, `SELVA_FLAG_${key}`, false)])
143
- ),
144
- branding: {
145
- name: env.SELVA_BRAND_NAME,
146
- copyrightName: env.SELVA_BRAND_COPYRIGHT_NAME,
147
- tagline: env.SELVA_BRAND_TAGLINE,
148
- description: env.SELVA_BRAND_DESCRIPTION
149
- },
150
- auth: pick(env, 'SELVA_AUTH_PROVIDER', registry.auth, defaults?.auth ?? 'local'),
151
- data,
152
- storage: pick(env, 'SELVA_STORAGE_PROVIDER', registry.storage, defaults?.storage ?? 'local'),
153
- solveMetrics: pickSolveMetrics(data)
154
- };
155
- };
156
- }
157
-
158
- async function loadConfigSource(
159
- options: CreateSelvaProvidersOptions
160
- ): Promise<SelvaConfig | SelvaConfigFactory> {
161
- if (!options.configPath) {
162
- return buildDefaultFactory(options);
163
- }
164
- const abs = resolvePath(process.cwd(), options.configPath);
165
- if (!existsSync(abs)) {
166
- throw new Error(
167
- `Config override path ${options.configPath} resolved to ${abs} which does not exist.`
168
- );
169
- }
170
- // Dynamic specifier — a bundler must not pre-resolve this at build time,
171
- // hence @vite-ignore. pathToFileURL keeps Windows absolute paths valid as
172
- // ESM specifiers.
173
- const mod = (await import(/* @vite-ignore */ pathToFileURL(abs).href)) as {
174
- default: SelvaConfig | SelvaConfigFactory;
175
- };
176
- return mod.default;
177
- }
178
-
179
- /**
180
- * Build the provider runtime. Loading the *config source* (default factory,
181
- * or the `configPath` override module) happens here and is cheap and
182
- * secret-free — a factory is just a function. The factory is NOT invoked:
183
- * that runs the registry's `fromEnv` factories, which validate required
184
- * secrets, and doing it at import/build time would make merely building the
185
- * app require a full runtime env. Provider instantiation is deferred to the
186
- * first `resolve()`.
187
- */
188
- export async function createSelvaProviders(
189
- env: EnvRecord,
190
- options: CreateSelvaProvidersOptions
191
- ): Promise<SelvaProviderRuntime> {
192
- const raw = await loadConfigSource(options);
193
- const onBoot = options.onBoot ?? console.info;
194
-
195
- let config: SelvaConfig | undefined;
196
- let solveMetricSink: ISolveMetricSink | undefined;
197
-
198
- function resolve(): SelvaConfig {
199
- if (config) return config;
200
- config = typeof raw === 'function' ? raw(env) : raw;
201
-
202
- // One-line boot summary so operators can confirm at a glance what got
203
- // wired without grepping env vars or reading the config file. Provider
204
- // names come from the IAuthProvider.name field; data/storage adapters
205
- // don't expose a name, so we infer from the constructor.
206
- onBoot(
207
- `[selva] providers wired: ` +
208
- `auth=${config.auth.name} ` +
209
- `data=${config.data.constructor.name} ` +
210
- `storage=${config.storage.constructor.name} ` +
211
- `tenancy=${config.tenancy ?? 'single'}` +
212
- (options.configPath ? ` config=${options.configPath}` : '')
213
- );
214
-
215
- return config;
216
- }
217
-
218
- return {
219
- resolve,
220
- tenancy: () => resolve().tenancy ?? 'single',
221
- branding: () => {
222
- const brand = resolve().branding ?? {};
223
- const name = brand.name?.trim() || 'Selva';
224
- return {
225
- name,
226
- copyrightName: brand.copyrightName?.trim() || name,
227
- tagline: brand.tagline?.trim() || 'Turn Grasshopper definitions into tools anyone can use.',
228
- description:
229
- brand.description?.trim() ||
230
- `Build and deploy interactive web applications powered by Grasshopper definitions with ${name}.`
231
- };
232
- },
233
- flag: (name) => isFlagEnabled(resolve(), name),
234
- solveMetricSink: () => {
235
- if (!solveMetricSink) {
236
- solveMetricSink = resolve().solveMetrics ?? new NoopSolveMetricSink();
237
- }
238
- return solveMetricSink;
239
- }
240
- };
241
- }
@@ -1,12 +0,0 @@
1
- // Configurable provider wiring — env-driven provider selection over a
2
- // caller-supplied registry, `selva.config.js` override hook, lazy memoized
3
- // instantiation. The app keeps its own getters (services, error reporting) as
4
- // its composition root; this is the reusable core underneath.
5
-
6
- export {
7
- createSelvaProviders,
8
- type CreateSelvaProvidersOptions,
9
- type ProviderFactory,
10
- type ProviderRegistry,
11
- type SelvaProviderRuntime
12
- } from './create-selva-providers.js';
@@ -1,63 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { createTokenCodec, MIN_TOKEN_SECRET_LENGTH } from '../token-codec.js';
3
-
4
- const SECRET = 'unit-test-secret-that-is-long-enough-to-pass';
5
-
6
- describe('createTokenCodec', () => {
7
- it('rejects a secret shorter than the minimum', () => {
8
- expect(() => createTokenCodec({ prefix: 'share_', secret: 'short' })).toThrow(
9
- /at least 32 characters/
10
- );
11
- expect(() =>
12
- createTokenCodec({ prefix: 'share_', secret: 'x'.repeat(MIN_TOKEN_SECRET_LENGTH - 1) })
13
- ).toThrow(/got 31/);
14
- });
15
-
16
- it('accepts a secret at exactly the minimum length', () => {
17
- expect(() =>
18
- createTokenCodec({ prefix: 'share_', secret: 'x'.repeat(MIN_TOKEN_SECRET_LENGTH) })
19
- ).not.toThrow();
20
- });
21
-
22
- it('mints prefixed, unique, URL-safe tokens', () => {
23
- const codec = createTokenCodec({ prefix: 'share_', secret: SECRET });
24
- const a = codec.mintRawToken();
25
- const b = codec.mintRawToken();
26
- expect(a).toMatch(/^share_[A-Za-z0-9_-]{43}$/); // 32 bytes → 43 base64url chars
27
- expect(a).not.toBe(b);
28
- });
29
-
30
- it('hashes deterministically, never echoing the raw token', () => {
31
- const codec = createTokenCodec({ prefix: 'share_', secret: SECRET });
32
- const raw = codec.mintRawToken();
33
- const hash = codec.hashToken(raw);
34
- expect(codec.hashToken(raw)).toBe(hash);
35
- expect(hash).not.toContain(raw);
36
- expect(hash).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
37
- });
38
-
39
- it('produces different hashes under different secrets (rotation invalidates)', () => {
40
- const a = createTokenCodec({ prefix: 'share_', secret: SECRET });
41
- const b = createTokenCodec({ prefix: 'share_', secret: SECRET + '-rotated' });
42
- const raw = a.mintRawToken();
43
- expect(a.hashToken(raw)).not.toBe(b.hashToken(raw));
44
- });
45
-
46
- it('hashesEqual: equal, unequal, and length-mismatch inputs', () => {
47
- const codec = createTokenCodec({ prefix: 'invite_', secret: SECRET });
48
- const hash = codec.hashToken(codec.mintRawToken());
49
- const other = codec.hashToken(codec.mintRawToken());
50
- expect(codec.hashesEqual(hash, hash)).toBe(true);
51
- expect(codec.hashesEqual(hash, other)).toBe(false);
52
- expect(codec.hashesEqual(hash, hash.slice(0, -1))).toBe(false);
53
- });
54
-
55
- it('looksLikeToken recognizes only its own prefix', () => {
56
- const share = createTokenCodec({ prefix: 'share_', secret: SECRET });
57
- const invite = createTokenCodec({ prefix: 'invite_', secret: SECRET });
58
- const raw = share.mintRawToken();
59
- expect(share.looksLikeToken(raw)).toBe(true);
60
- expect(invite.looksLikeToken(raw)).toBe(false);
61
- expect(share.looksLikeToken('random-string')).toBe(false);
62
- });
63
- });
@@ -1,8 +0,0 @@
1
- // HMAC token codec for capability-URL tokens (share links, invites, …).
2
-
3
- export {
4
- createTokenCodec,
5
- MIN_TOKEN_SECRET_LENGTH,
6
- type TokenCodec,
7
- type TokenCodecConfig
8
- } from './token-codec.js';
@@ -1,86 +0,0 @@
1
- import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
2
-
3
- /**
4
- * HMAC token codec — the primitive behind capability-URL tokens (share links,
5
- * invites, and anything shaped like them).
6
- *
7
- * ## Two-part design
8
- *
9
- * Each minted token involves TWO secrets:
10
- *
11
- * 1. **Per-token secret** — generated in `mintRawToken()` from 32 random
12
- * bytes, returned to the caller exactly once at mint time. Different
13
- * every token. This is what end-users carry in `?token=…`.
14
- *
15
- * 2. **Instance-wide HMAC secret** (`config.secret`) — used to hash tokens
16
- * for storage. The store sees only `HMAC-SHA256(secret, token)`. A
17
- * DB-only leak therefore can't be replayed: the attacker would need the
18
- * secret too, and it lives in env, not the database.
19
- *
20
- * ## Format
21
- * raw = `<prefix><base64url(32 random bytes)>`
22
- * hash = base64url( HMAC-SHA256(secret, raw) )
23
- *
24
- * The resolution path hashes the supplied token with the same secret and looks
25
- * up by exact match against the stored hash.
26
- *
27
- * Rotating the secret invalidates every token minted under it — give each
28
- * token family (each prefix) its own secret so they rotate independently.
29
- */
30
-
31
- /**
32
- * Minimum accepted secret length. Matches the "random ≥32-byte string"
33
- * guidance everywhere these secrets are documented; enforced here so a
34
- * short dev secret can't reach production silently (presence-only checks
35
- * let a 4-char secret through).
36
- */
37
- export const MIN_TOKEN_SECRET_LENGTH = 32;
38
-
39
- export interface TokenCodecConfig {
40
- /** Token-family marker prepended to every raw token, e.g. `share_`. */
41
- prefix: string;
42
- /** Instance-wide HMAC secret. Must be at least 32 characters. */
43
- secret: string;
44
- }
45
-
46
- export interface TokenCodec {
47
- /** Mint: generate a fresh raw token. Show to the caller exactly once. */
48
- mintRawToken(): string;
49
- /** Hash a raw token to its store-side representation. */
50
- hashToken(raw: string): string;
51
- /** Constant-time equality for two stored hashes. */
52
- hashesEqual(a: string, b: string): boolean;
53
- /** Recognize this codec's token format on inbound requests. */
54
- looksLikeToken(value: string): boolean;
55
- /** The configured prefix, for callers that build URLs or error copy. */
56
- readonly prefix: string;
57
- }
58
-
59
- export function createTokenCodec(config: TokenCodecConfig): TokenCodec {
60
- const { prefix, secret } = config;
61
- if (secret.length < MIN_TOKEN_SECRET_LENGTH) {
62
- throw new Error(
63
- `Secret for "${prefix}" tokens must be at least ${MIN_TOKEN_SECRET_LENGTH} characters ` +
64
- `(got ${secret.length}). Generate one with: openssl rand -base64 32`
65
- );
66
- }
67
-
68
- return {
69
- prefix,
70
- mintRawToken(): string {
71
- return prefix + randomBytes(32).toString('base64url');
72
- },
73
- hashToken(raw: string): string {
74
- return createHmac('sha256', secret).update(raw).digest('base64url');
75
- },
76
- hashesEqual(a: string, b: string): boolean {
77
- const ab = Buffer.from(a);
78
- const bb = Buffer.from(b);
79
- if (ab.length !== bb.length) return false;
80
- return timingSafeEqual(ab, bb);
81
- },
82
- looksLikeToken(value: string): boolean {
83
- return value.startsWith(prefix);
84
- }
85
- };
86
- }