@sanoma-ai/workflows 0.11.6 → 0.11.11

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 (51) hide show
  1. package/README.md +17 -7
  2. package/dist/change.d.ts +2 -4
  3. package/dist/change.js +2 -3
  4. package/dist/change.js.map +1 -1
  5. package/dist/client.d.ts +6 -0
  6. package/dist/client.js +5 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.js +2 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/describe.js +1 -0
  11. package/dist/describe.js.map +1 -1
  12. package/dist/description.d.ts +12 -0
  13. package/dist/description.js +3 -0
  14. package/dist/description.js.map +1 -1
  15. package/dist/host-derive.d.ts +3 -0
  16. package/dist/host-derive.js +9 -0
  17. package/dist/host-derive.js.map +1 -1
  18. package/dist/host.d.ts +2 -0
  19. package/dist/host.js +2 -1
  20. package/dist/host.js.map +1 -1
  21. package/dist/ledger.d.ts +6 -0
  22. package/dist/ledger.js +8 -0
  23. package/dist/ledger.js.map +1 -1
  24. package/dist/runtime-ops.d.ts +4 -1
  25. package/dist/runtime-ops.js +37 -34
  26. package/dist/runtime-ops.js.map +1 -1
  27. package/dist/secret-store.d.ts +4 -0
  28. package/dist/secret-store.js +8 -0
  29. package/dist/secret-store.js.map +1 -1
  30. package/dist/shared.d.ts +2 -0
  31. package/dist/shared.js +2 -0
  32. package/dist/shared.js.map +1 -1
  33. package/dist/source.d.ts +2 -0
  34. package/dist/source.js.map +1 -1
  35. package/dist/tokens.d.ts +10 -0
  36. package/dist/tokens.js +40 -2
  37. package/dist/tokens.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/change.ts +10 -3
  40. package/src/client.ts +14 -1
  41. package/src/config.ts +7 -0
  42. package/src/describe.ts +1 -0
  43. package/src/description.ts +3 -0
  44. package/src/host-derive.ts +11 -0
  45. package/src/host.ts +2 -1
  46. package/src/ledger.ts +12 -0
  47. package/src/runtime-ops.ts +45 -35
  48. package/src/secret-store.ts +16 -3
  49. package/src/shared.ts +3 -0
  50. package/src/source.ts +2 -0
  51. package/src/tokens.ts +55 -2
package/src/config.ts CHANGED
@@ -417,6 +417,11 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
417
417
  drivers: driversByVendor,
418
418
  builtInStore: !config.secrets?.store,
419
419
  signingKeyBound: config.secrets?.bindings?.SANOMA_SIGNING_KEY !== undefined,
420
+ hostEnvironment: Object.fromEntries(
421
+ Object.entries(config.secrets?.bindings ?? {}).flatMap(([name, binding]) =>
422
+ binding.host && binding.env !== undefined ? [[binding.env, name]] : [],
423
+ ),
424
+ ),
420
425
  ...(isOp(config.login?.sender) && { sender: config.login.sender.vendor }),
421
426
  });
422
427
  if (types.size) {
@@ -744,6 +749,7 @@ async function readDeclared(
744
749
  drivers: ReadonlyMap<string, Driver>;
745
750
  builtInStore: boolean;
746
751
  signingKeyBound: boolean;
752
+ hostEnvironment: Readonly<Record<string, string>>;
747
753
  sender?: string;
748
754
  },
749
755
  ): Promise<Omit<ApplyDeclared, "rebinds">> {
@@ -765,6 +771,7 @@ async function readDeclared(
765
771
  ...(run && { secrets: run.state.secrets }),
766
772
  builtInStore: options.builtInStore,
767
773
  signingKeyBound: options.signingKeyBound,
774
+ hostEnvironment: options.hostEnvironment,
768
775
  ...(options.sender !== undefined && { sender: options.sender }),
769
776
  mint: !!options.mint && !!run && !run.sandbox,
770
777
  });
package/src/describe.ts CHANGED
@@ -189,6 +189,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
189
189
  .map((name) => `${id}.${name}`)
190
190
  .toSorted(),
191
191
  ...(capabilities.length ? { capabilities } : {}),
192
+ ...(spec.host && { host: { lifetime: spec.host.capabilities.lifetime } }),
192
193
  };
193
194
  })
194
195
  .toSorted((a, b) => a.id.localeCompare(b.id));
@@ -2,6 +2,7 @@
2
2
  // types inferred from them. zod only, no Node: a page can parse a description it is sent, and the
3
3
  // app extends `ConfigDescriptionSchema` with what it adds (`AppConfigSchema`).
4
4
  import { z } from "zod";
5
+ import { LIFETIMES } from "./host.ts";
5
6
  import type { Outline } from "./outline.ts";
6
7
  import type { ResourceFields } from "./resource.ts";
7
8
  import { EFFECTS, FIELD_TYPES } from "./shared.ts";
@@ -322,6 +323,8 @@ export const ResourceTypeEntrySchema = z.strictObject({
322
323
  ops: z.array(z.string()),
323
324
  /** The contracts it meets (`CAPABILITIES`), in that order; absent when it meets none. */
324
325
  capabilities: z.array(z.enum(CAPABILITIES)).optional(),
326
+ /** For a host (`HostSpec`), how it keeps the runtime up (`HostLifetime`); absent for any other type. */
327
+ host: z.strictObject({ lifetime: z.enum(LIFETIMES) }).optional(),
325
328
  });
326
329
  export type ResourceTypeEntry = z.infer<typeof ResourceTypeEntrySchema>;
327
330
 
@@ -78,6 +78,8 @@ export interface HostFacts {
78
78
  unbound?: ReadonlyMap<string, readonly string[]>;
79
79
  /** True when the config binds `SANOMA_SIGNING_KEY`: the host is given that key, else its own (`HOST_SIGNING_KEY`). */
80
80
  signingKeyBound?: boolean;
81
+ /** Explicit environment delivery: destination variable to logical secret name, never values. */
82
+ hostEnvironment?: Readonly<Record<string, string>>;
81
83
  /**
82
84
  * While the config has no deploy-key type, the GitHub repositories a host may clone (`origin`'s,
83
85
  * a declared `source`'s) that GitHub shows to no one: private (or not there). One GitHub does
@@ -352,10 +354,17 @@ export function deriveHosts(
352
354
  ]);
353
355
  const carried = [...vendors].flatMap((vendor) => facts.carried.get(vendor) ?? []);
354
356
  for (const name of [...carried, ...facts.githubApp]) secrets[name] = secret(name);
357
+ for (const [environment, name] of Object.entries(facts.hostEnvironment ?? {})) {
358
+ if (Object.hasOwn(secrets, environment) && secrets[environment]!.$secret !== name)
359
+ problem(`${environment} has a derived host binding and cannot be replaced by ${name}`);
360
+ else if (!Object.hasOwn(secrets, environment))
361
+ Object.defineProperty(secrets, environment, { value: secret(name), enumerable: true });
362
+ }
355
363
  // One the driver reads here from the environment alone would be left off the host, and a
356
364
  // Worker updated without it would lose it.
357
365
  for (const vendor of vendors) {
358
366
  for (const name of facts.unbound?.get(vendor) ?? []) {
367
+ if (Object.hasOwn(facts.hostEnvironment ?? {}, name)) continue;
359
368
  problem(
360
369
  `${name} is read from the environment here, and has no binding, so the host would not be given it: bind it in sanoma.config.ts, \`secrets: { bindings: { ${name}: { env: "${name}" } } }\`, or store it on the ${vendor} connector page`,
361
370
  );
@@ -428,6 +437,7 @@ export async function hostFacts(options: {
428
437
  builtInStore: boolean;
429
438
  /** True when the config binds `SANOMA_SIGNING_KEY` (`{ env }` or `{ bootstrap }`). */
430
439
  signingKeyBound?: boolean;
440
+ hostEnvironment?: Readonly<Record<string, string>>;
431
441
  sender?: string;
432
442
  mint: boolean;
433
443
  }): Promise<HostFacts | undefined> {
@@ -484,6 +494,7 @@ export async function hostFacts(options: {
484
494
  carried,
485
495
  ...(unbound.size > 0 && { unbound }),
486
496
  ...(options.signingKeyBound && { signingKeyBound: true }),
497
+ ...(options.hostEnvironment && { hostEnvironment: options.hostEnvironment }),
487
498
  ...(options.sender !== undefined && { sender: options.sender }),
488
499
  githubApp,
489
500
  ...(sourceKey !== undefined && { sourceKey }),
package/src/host.ts CHANGED
@@ -130,7 +130,8 @@ export const UNNAMED = "(the repository's name)";
130
130
  /** The port the runtime image's app listens on (its `PORT`): a host that derives runs it there. */
131
131
  export const RUNTIME_PORT = 3000;
132
132
 
133
- const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-when-quiet"];
133
+ /** Every `HostLifetime`, for a schema of one. */
134
+ export const LIFETIMES = ["long-lived", "stops-when-quiet"] as const satisfies readonly HostLifetime[];
134
135
 
135
136
  /** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
136
137
  export function hasPath(schema: z.ZodType, path: string): boolean {
package/src/ledger.ts CHANGED
@@ -190,6 +190,18 @@ export async function lastSignIns(
190
190
  return Object.fromEntries(last.flat());
191
191
  }
192
192
 
193
+ /** Who minted the latest invite for `person`, and until when (ms since the epoch): from their `invite:<person>` records; undefined when none was. */
194
+ export async function lastInvite(
195
+ store: LedgerStore,
196
+ person: string,
197
+ ): Promise<{ by: string; at: number; expiresAt: number } | undefined> {
198
+ const records = await store.read(`invite:${person}`);
199
+ const latest = records.reduce<LedgerRecord | undefined>((a, r) => (a && a.at > r.at ? a : r), undefined);
200
+ return latest?.type === "invite.minted"
201
+ ? { by: latest.by, at: latest.at, expiresAt: Date.parse(latest.expiresAt) }
202
+ : undefined;
203
+ }
204
+
193
205
  /**
194
206
  * The `ctx.all` member a record was written in. `id` is `all:<seq>`, the run's next `seq` when
195
207
  * the `ctx.all` began, so a replay names it the same; `index` is the member's position, `size`
@@ -6,7 +6,7 @@ import { generateSecret, SECRET_NAME } from "./secret.ts";
6
6
  import { sealInvite } from "./seal.ts";
7
7
  import { errorMessage } from "./shared.ts";
8
8
  import { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
9
- import { memoryTokens, type Tokens } from "./tokens.ts";
9
+ import { memoryTokens, newApiToken, type Tokens } from "./tokens.ts";
10
10
  import type { InspectRun } from "./run-status.ts";
11
11
  import {
12
12
  identityProvisioningCode,
@@ -26,11 +26,17 @@ import {
26
26
  // registers them in every config with their driver (`runtimeDriver`), so a policy may name them;
27
27
  // a sandbox run calls `sandboxRuntimeDriver`, over its own secrets and tokens, posting nothing.
28
28
 
29
- /** A secret's name, as the store takes it (upper case), other than the runtime's own (`SANOMA_…`: its keys and credentials). */
30
- const SecretName = z
31
- .string()
32
- .regex(SECRET_NAME, "a secret's name: upper case, A-Z, 0-9 and _")
33
- .refine((name) => !name.startsWith("SANOMA_"), "a SANOMA_ name is the runtime's own, not a workflow's to use");
29
+ const StoredSecretName = z.string().regex(SECRET_NAME, "a secret's name: upper case, A-Z, 0-9 and _");
30
+ /** Generation destinations exclude the runtime's keys and credentials. */
31
+ const SecretName = StoredSecretName.refine(
32
+ (name) => !name.startsWith("SANOMA_"),
33
+ "a SANOMA_ name is the runtime's own, not a workflow's to use",
34
+ );
35
+ /** Database exports are stored values a lifecycle workflow can retire; bootstrap keys remain protected. */
36
+ const DeletableSecretName = StoredSecretName.refine(
37
+ (name) => !name.startsWith("SANOMA_") || /^SANOMA_DATABASE_URL_[A-Z0-9_]+$/.test(name),
38
+ "a SANOMA_ name may be deleted only when it is an exported database URL",
39
+ );
34
40
 
35
41
  /** How long an instance gets to answer `POST /auth/invite`. */
36
42
  const INVITE_TIMEOUT_MS = 10_000;
@@ -143,8 +149,9 @@ export const sanoma = defineConnector("sanoma", {
143
149
  delete: {
144
150
  effect: "write",
145
151
  idempotent: true,
146
- description: "Delete a stored secret; one not stored is nothing to do",
147
- input: z.object({ name: SecretName }),
152
+ description:
153
+ "Delete a stored secret or exported database URL; one not stored is nothing to do. Runtime bootstrap secrets remain protected",
154
+ input: z.object({ name: DeletableSecretName }),
148
155
  output: z.object({}),
149
156
  target: ({ name }: { name: string }) => name,
150
157
  createsSecrets: ({ name }: { name: string }) => [name],
@@ -174,13 +181,23 @@ export const sanoma = defineConnector("sanoma", {
174
181
  token: {
175
182
  mint: {
176
183
  effect: "access",
184
+ idempotent: true,
177
185
  description:
178
186
  "Mint an api token acting for a subject in groups, store it under a name, and answer its id (never the token)",
179
- input: z.object({
180
- subject: z.string().regex(/^\S+$/, "a principal's id, such as visitor:better-auth:<id>"),
181
- groups: z.array(z.string().min(1)),
182
- store: SecretName.describe("The secret the token is stored under"),
183
- }),
187
+ input: z
188
+ .object({
189
+ id: z.guid().optional().describe("A recorded UUID-shaped token ID for recovery across workflow runs"),
190
+ reference: z
191
+ .string()
192
+ .min(1)
193
+ .max(1024)
194
+ .optional()
195
+ .describe("A stable non-secret provisioning reference, recorded before minting; alternative to id"),
196
+ subject: z.string().regex(/^\S+$/, "a principal's id, such as visitor:better-auth:<id>"),
197
+ groups: z.array(z.string().min(1)),
198
+ store: SecretName.describe("The secret the token is stored under"),
199
+ })
200
+ .refine(({ id, reference }) => id === undefined || reference === undefined, "Pass id or reference, not both"),
184
201
  output: z.object({ id: z.string() }),
185
202
  target: ({ subject }: { subject: string }) => subject,
186
203
  createsSecrets: ({ store }: { store: string }) => [store],
@@ -199,9 +216,10 @@ export const sanoma = defineConnector("sanoma", {
199
216
  /** A `DriverError` that a retry would not change. */
200
217
  const refuse = (message: string) => new DriverError(`sanoma: ${message}`, { retryable: false });
201
218
 
202
- /** The token id a call mints, from its idempotency key: the same on every try of one call, so a retry finds its own. */
203
- function tokenIdOf(call: CallContext) {
204
- const hex = createHash("sha256").update(call.idempotencyKey).digest("hex");
219
+ /** Stable identity from a recorded reference, or this call's idempotency key when none was supplied. */
220
+ function tokenIdOf(call: CallContext, reference?: string) {
221
+ const key = reference === undefined ? call.idempotencyKey : JSON.stringify(["sanoma.token.reference", reference]);
222
+ const hex = createHash("sha256").update(key).digest("hex");
205
223
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
206
224
  }
207
225
 
@@ -334,27 +352,19 @@ export function runtimeDriver({
334
352
  },
335
353
  },
336
354
  token: {
337
- async mint({ subject, groups, store }, call) {
338
- const id = tokenIdOf(call);
339
- const held = await call.secrets.get(store);
340
- if (held !== undefined) {
341
- // This call's own, stored by a try whose answer was lost.
342
- if ((await tokens.resolve(held))?.principal.id === `token:${id}`) return { id };
343
- throw refuse(`${store} holds a value already: delete it, or store the token under another name`);
344
- }
345
- let token: string;
355
+ async mint({ id: requestedId, reference, subject, groups, store }, call) {
356
+ const id = requestedId ?? tokenIdOf(call, reference);
357
+ // Persist the bearer before registration. A lost store or database response can then
358
+ // retry the same value; no live token is created whose only copy was in this process.
359
+ const token = await call.secrets.ensure(store, newApiToken);
360
+ if (!/^sca_[A-Za-z0-9_-]{43}$/.test(token)) throw refuse(`${store} holds a value that is not an api token`);
361
+ let registered: boolean;
346
362
  try {
347
- ({ token } = await tokens.mint({ id, subject, groups }));
348
- } catch (err) {
349
- // A try that minted this id and stopped before storing it left a token nobody holds: revoked here.
350
- if ((await tokens.subjectOf(id)) === undefined) throw err;
351
- await tokens.revoke({ id });
352
- throw refuse(`an earlier try minted token ${id} and stopped before storing it: it is revoked, start again`);
353
- }
354
- if ((await call.secrets.ensure(store, () => token)) !== token) {
355
- await tokens.revoke({ id });
356
- throw refuse(`${store} was stored by someone else meanwhile: the token minted is revoked`);
363
+ registered = await tokens.register({ id, token, subject, groups });
364
+ } catch {
365
+ throw new DriverError("sanoma: token registration unavailable", { retryable: true });
357
366
  }
367
+ if (!registered) throw refuse(`${store} holds a token with conflicting or revoked registration`);
358
368
  return { id };
359
369
  },
360
370
  async revoke({ id }) {
@@ -79,9 +79,16 @@ export const isSecretStoreFactory = (store: SecretStore | SecretStoreFactory): s
79
79
  "kind" in store && store.kind === "runtime";
80
80
 
81
81
  export type SecretBinding =
82
- | { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never }
83
- | { env: string; management?: "external"; path?: never; bootstrap?: never }
84
- | { bootstrap: DeploymentKeyName; management?: "external"; env?: never; path?: never };
82
+ | { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never; host?: never }
83
+ | {
84
+ env: string;
85
+ /** Deliver this binding to declared deriving hosts under its environment name. */
86
+ host?: boolean;
87
+ management?: "external";
88
+ path?: never;
89
+ bootstrap?: never;
90
+ }
91
+ | { bootstrap: DeploymentKeyName; management?: "external"; env?: never; path?: never; host?: never };
85
92
 
86
93
  export interface SecretConfiguration {
87
94
  /** Omitted: built-in encrypted Postgres, supplied by the runtime. */
@@ -161,8 +168,10 @@ export function checkSecretConfiguration(config: SecretConfiguration): void {
161
168
  (!config.bindings || typeof config.bindings !== "object" || Array.isArray(config.bindings))
162
169
  )
163
170
  invalid();
171
+ const hostEnvironment = new Set<string>();
164
172
  for (const [name, b] of Object.entries(config.bindings ?? {})) {
165
173
  if (!namePattern.test(name) || !b || typeof b !== "object") invalid();
174
+ if (b.host !== undefined && (typeof b.env !== "string" || typeof b.host !== "boolean")) invalid();
166
175
  if (DEPLOYMENT_KEYS.includes(name) && !("env" in b) && !("bootstrap" in b)) invalid();
167
176
  if ("bootstrap" in b) {
168
177
  if (
@@ -180,6 +189,10 @@ export function checkSecretConfiguration(config: SecretConfiguration): void {
180
189
  (b.management !== undefined && b.management !== "external")
181
190
  )
182
191
  invalid();
192
+ if (b.host) {
193
+ if (hostEnvironment.has(b.env!)) invalid();
194
+ hostEnvironment.add(b.env!);
195
+ }
183
196
  } else if (!nonempty(b.path) || !["sanoma", "external"].includes(b.management)) invalid();
184
197
  }
185
198
  }
package/src/shared.ts CHANGED
@@ -387,6 +387,9 @@ export function refusalOf(
387
387
  /** A declared resource as the people functions read it: only the runtime's people types count. */
388
388
  export type PeopleResource = Pick<DeclaredResource, "vendor" | "type" | "desired">;
389
389
 
390
+ /** The group the runtime holds a change to the rules for, and the one that hands out invite links. */
391
+ export const ADMIN = "admin";
392
+
390
393
  /** The group every agent is in, the runtime's own among them: no data file declares it. */
391
394
  export const AGENTS = "agents";
392
395
 
package/src/source.ts CHANGED
@@ -322,6 +322,8 @@ export interface RemoteHalf {
322
322
  webhook?(request: Request, context: RemoteContext): Promise<RemoteEvent[]>;
323
323
  /** The accounts the company's app on the host is installed on: where it acts with no person's token. */
324
324
  owners?(context: RemoteContext): Promise<{ login: string; kind: "user" | "organization" }[]>;
325
+ /** The permissions the company's app on the host holds, by name (`contents: "write"`), as the host lists them; undefined without the app's keys. */
326
+ permissions?(context: RemoteContext): Promise<Record<string, string> | undefined>;
325
327
  }
326
328
 
327
329
  /** The remote half among `remotes` (`ResolvedConfig.remotes`) whose host `url` is on, with its vendor and the repository. */
package/src/tokens.ts CHANGED
@@ -11,6 +11,22 @@ import { generateSecret, tokenHash as hashOf } from "./secret.ts";
11
11
  /** What every token starts with, so a bearer that is not one is told at a glance. */
12
12
  const PREFIX = "sca_";
13
13
 
14
+ /** Register only a generated bearer retained by trusted provisioning, never a workflow value. */
15
+ export interface TokenRegistration {
16
+ id: string;
17
+ token: string;
18
+ subject: string;
19
+ groups: readonly string[];
20
+ }
21
+
22
+ export const newApiToken = () => `${PREFIX}${generateSecret()}`;
23
+ const groupsOf = (groups: readonly string[]) => [...new Set(groups)].toSorted();
24
+ const registrationOf = (input: TokenRegistration) => {
25
+ if (!input.id || !input.subject || !/^sca_[A-Za-z0-9_-]{43}$/.test(input.token))
26
+ throw new Error("Invalid token registration");
27
+ return { ...input, hash: hashOf(input.token), groups: groupsOf(input.groups) };
28
+ };
29
+
14
30
  /** The api tokens' store, `SanomaClient.tokens`. */
15
31
  export interface Tokens {
16
32
  /**
@@ -19,6 +35,8 @@ export interface Tokens {
19
35
  * shown only here. An id another token has is refused.
20
36
  */
21
37
  mint(token: { id?: string; subject: string; groups: readonly string[] }): Promise<{ id: string; token: string }>;
38
+ /** Register a saved bearer, or confirm the identical active registration. Conflicts and revocation return false. */
39
+ register(token: TokenRegistration): Promise<boolean>;
22
40
  /** Revokes the token `id`: it resolves to nobody from now on. Revoking one revoked or missing does nothing. */
23
41
  revoke(token: { id: string }): Promise<void>;
24
42
  /**
@@ -33,7 +51,7 @@ export interface Tokens {
33
51
  export function tokenStore(db: () => Promise<Pool>): Tokens {
34
52
  return {
35
53
  async mint({ id = randomUUID(), subject, groups }) {
36
- const token = `${PREFIX}${generateSecret()}`;
54
+ const token = newApiToken();
37
55
  await (
38
56
  await db()
39
57
  ).query(`INSERT INTO ${TABLE} (id, hash, subject, groups) VALUES ($1, $2, $3, $4::jsonb)`, [
@@ -44,6 +62,27 @@ export function tokenStore(db: () => Promise<Pool>): Tokens {
44
62
  ]);
45
63
  return { id, token };
46
64
  },
65
+ async register(input) {
66
+ const { id, hash, subject, groups } = registrationOf(input);
67
+ try {
68
+ const result = await (
69
+ await db()
70
+ ).query(
71
+ `INSERT INTO ${TABLE} (id, hash, subject, groups) VALUES ($1,$2,$3,$4::jsonb)
72
+ ON CONFLICT (id) DO UPDATE SET id=EXCLUDED.id
73
+ WHERE ${TABLE}.hash=EXCLUDED.hash AND ${TABLE}.subject=EXCLUDED.subject
74
+ AND ${TABLE}.groups @> EXCLUDED.groups AND ${TABLE}.groups <@ EXCLUDED.groups
75
+ AND ${TABLE}.revoked_at IS NULL
76
+ RETURNING id`,
77
+ [id, hash, subject, JSON.stringify(groups)],
78
+ );
79
+ return result.rowCount === 1;
80
+ } catch (error) {
81
+ // A bearer already registered under another ID cannot acquire a second identity.
82
+ if ((error as { code?: string }).code === "23505") return false;
83
+ throw error;
84
+ }
85
+ },
47
86
  async revoke({ id }) {
48
87
  await (await db()).query(`UPDATE ${TABLE} SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, [id]);
49
88
  },
@@ -75,10 +114,24 @@ export function memoryTokens(): Tokens {
75
114
  return {
76
115
  async mint({ id = randomUUID(), subject, groups }) {
77
116
  if (rows.has(id)) throw new Error(`A token with the id ${id} exists`);
78
- const token = `${PREFIX}${generateSecret()}`;
117
+ const token = newApiToken();
79
118
  rows.set(id, { hash: hashOf(token), subject, groups: [...groups], revoked: false });
80
119
  return { id, token };
81
120
  },
121
+ async register(input) {
122
+ const { id, hash, subject, groups } = registrationOf(input);
123
+ const row = rows.get(id);
124
+ if (row)
125
+ return (
126
+ !row.revoked &&
127
+ row.hash === hash &&
128
+ row.subject === subject &&
129
+ JSON.stringify(groupsOf(row.groups)) === JSON.stringify(groups)
130
+ );
131
+ if ([...rows.values()].some((other) => other.hash === hash)) return false;
132
+ rows.set(id, { hash, subject, groups, revoked: false });
133
+ return true;
134
+ },
82
135
  async revoke({ id }) {
83
136
  const row = rows.get(id);
84
137
  if (row) row.revoked = true;