@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.
- package/README.md +17 -7
- package/dist/change.d.ts +2 -4
- package/dist/change.js +2 -3
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +6 -0
- package/dist/client.js +5 -1
- package/dist/client.js.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/describe.js +1 -0
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +12 -0
- package/dist/description.js +3 -0
- package/dist/description.js.map +1 -1
- package/dist/host-derive.d.ts +3 -0
- package/dist/host-derive.js +9 -0
- package/dist/host-derive.js.map +1 -1
- package/dist/host.d.ts +2 -0
- package/dist/host.js +2 -1
- package/dist/host.js.map +1 -1
- package/dist/ledger.d.ts +6 -0
- package/dist/ledger.js +8 -0
- package/dist/ledger.js.map +1 -1
- package/dist/runtime-ops.d.ts +4 -1
- package/dist/runtime-ops.js +37 -34
- package/dist/runtime-ops.js.map +1 -1
- package/dist/secret-store.d.ts +4 -0
- package/dist/secret-store.js +8 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +2 -0
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +2 -0
- package/dist/source.js.map +1 -1
- package/dist/tokens.d.ts +10 -0
- package/dist/tokens.js +40 -2
- package/dist/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/change.ts +10 -3
- package/src/client.ts +14 -1
- package/src/config.ts +7 -0
- package/src/describe.ts +1 -0
- package/src/description.ts +3 -0
- package/src/host-derive.ts +11 -0
- package/src/host.ts +2 -1
- package/src/ledger.ts +12 -0
- package/src/runtime-ops.ts +45 -35
- package/src/secret-store.ts +16 -3
- package/src/shared.ts +3 -0
- package/src/source.ts +2 -0
- 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));
|
package/src/description.ts
CHANGED
|
@@ -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
|
|
package/src/host-derive.ts
CHANGED
|
@@ -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
|
-
|
|
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`
|
package/src/runtime-ops.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
147
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
/**
|
|
203
|
-
function tokenIdOf(call: CallContext) {
|
|
204
|
-
const
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
348
|
-
} catch
|
|
349
|
-
|
|
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 }) {
|
package/src/secret-store.ts
CHANGED
|
@@ -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
|
-
| {
|
|
84
|
-
|
|
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 =
|
|
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 =
|
|
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;
|