@sanoma-ai/workflows 0.10.2 → 0.10.6
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/CLOUD-IDENTITY.md +128 -17
- package/README.md +56 -13
- package/dist/apply.d.ts +11 -4
- package/dist/apply.js +127 -36
- package/dist/apply.js.map +1 -1
- package/dist/call.d.ts +5 -2
- package/dist/call.js +65 -20
- package/dist/call.js.map +1 -1
- package/dist/change.d.ts +5 -6
- package/dist/change.js +6 -9
- package/dist/change.js.map +1 -1
- package/dist/client.d.ts +18 -0
- package/dist/client.js +35 -3
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.d.ts +5 -1
- package/dist/cloud-identity.js +13 -77
- package/dist/cloud-identity.js.map +1 -1
- package/dist/config.d.ts +45 -4
- package/dist/config.js +73 -11
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.js +7 -0
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +26 -13
- package/dist/deadlines.js +50 -11
- package/dist/deadlines.js.map +1 -1
- package/dist/define.d.ts +29 -0
- package/dist/define.js +3 -0
- package/dist/define.js.map +1 -1
- package/dist/deployment-key.d.ts +20 -1
- package/dist/deployment-key.js +28 -3
- package/dist/deployment-key.js.map +1 -1
- package/dist/describe.js +28 -1
- package/dist/describe.js.map +1 -1
- package/dist/description.d.ts +3 -0
- package/dist/description.js +2 -0
- package/dist/description.js.map +1 -1
- package/dist/dns.d.ts +20 -0
- package/dist/dns.js +26 -0
- package/dist/dns.js.map +1 -0
- package/dist/drift.d.ts +4 -2
- package/dist/drift.js +13 -4
- package/dist/drift.js.map +1 -1
- package/dist/host.d.ts +13 -4
- package/dist/host.js +14 -5
- package/dist/host.js.map +1 -1
- package/dist/identity-broker.d.ts +14 -0
- package/dist/identity-broker.js +84 -0
- package/dist/identity-broker.js.map +1 -0
- package/dist/identity-provisioning.d.ts +29 -0
- package/dist/identity-provisioning.js +178 -0
- package/dist/identity-provisioning.js.map +1 -0
- package/dist/identity-service.d.ts +25 -0
- package/dist/identity-service.js +148 -0
- package/dist/identity-service.js.map +1 -0
- package/dist/index.d.ts +11 -6
- package/dist/index.js +9 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +30 -1
- package/dist/ledger.js +31 -0
- package/dist/ledger.js.map +1 -1
- package/dist/login-code.d.ts +17 -0
- package/dist/login-code.js +32 -0
- package/dist/login-code.js.map +1 -0
- package/dist/op.d.ts +85 -10
- package/dist/op.js +20 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.js +3 -3
- package/dist/outline.js.map +1 -1
- package/dist/provisioning-identity.d.ts +27 -0
- package/dist/provisioning-identity.js +81 -0
- package/dist/provisioning-identity.js.map +1 -0
- package/dist/resource.d.ts +73 -10
- package/dist/resource.js +103 -14
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +4 -2
- package/dist/resources.js +16 -6
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +3 -0
- package/dist/run.js.map +1 -1
- package/dist/runtime-ops.d.ts +182 -0
- package/dist/runtime-ops.js +294 -0
- package/dist/runtime-ops.js.map +1 -0
- package/dist/scenario.d.ts +6 -3
- package/dist/scenario.js +13 -1
- package/dist/scenario.js.map +1 -1
- package/dist/seal.d.ts +27 -0
- package/dist/seal.js +76 -0
- package/dist/seal.js.map +1 -0
- package/dist/secret-store.d.ts +6 -4
- package/dist/secret-store.js +11 -6
- package/dist/secret-store.js.map +1 -1
- package/dist/secret.d.ts +7 -0
- package/dist/secret.js +13 -1
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +15 -4
- package/dist/shared.js +27 -7
- package/dist/shared.js.map +1 -1
- package/dist/ssh-key.d.ts +11 -0
- package/dist/ssh-key.js +44 -0
- package/dist/ssh-key.js.map +1 -0
- package/dist/tokens.d.ts +35 -0
- package/dist/tokens.js +71 -0
- package/dist/tokens.js.map +1 -0
- package/dist/wakeup-service.js +2 -7
- package/dist/wakeup-service.js.map +1 -1
- package/dist/wakeup.d.ts +18 -0
- package/dist/wakeup.js +15 -0
- package/dist/wakeup.js.map +1 -1
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +17 -9
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -1
- package/package.json +5 -1
- package/src/apply.ts +166 -48
- package/src/call.ts +88 -18
- package/src/change.ts +9 -20
- package/src/client.ts +48 -7
- package/src/cloud-identity.ts +16 -76
- package/src/config.ts +116 -18
- package/src/db.ts +8 -1
- package/src/deadlines.ts +66 -20
- package/src/define.ts +33 -0
- package/src/deployment-key.ts +34 -8
- package/src/describe.ts +39 -2
- package/src/description.ts +2 -0
- package/src/dns.ts +29 -0
- package/src/drift.ts +13 -4
- package/src/host.ts +22 -6
- package/src/identity-broker.ts +94 -0
- package/src/identity-provisioning.ts +208 -0
- package/src/identity-service.ts +179 -0
- package/src/index.ts +20 -1
- package/src/ledger.ts +52 -1
- package/src/login-code.ts +52 -0
- package/src/op.ts +94 -11
- package/src/outline.ts +3 -3
- package/src/provisioning-identity.ts +106 -0
- package/src/resource.ts +155 -20
- package/src/resources.ts +14 -4
- package/src/run.ts +3 -0
- package/src/runtime-ops.ts +313 -0
- package/src/scenario.ts +24 -6
- package/src/seal.ts +95 -0
- package/src/secret-store.ts +12 -9
- package/src/secret.ts +16 -1
- package/src/shared.ts +31 -7
- package/src/ssh-key.ts +48 -0
- package/src/tokens.ts +100 -0
- package/src/wakeup-service.ts +2 -8
- package/src/wakeup.ts +21 -0
- package/src/worker.ts +29 -11
package/src/drift.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
3
|
import { isRunControlError } from "./errors.ts";
|
|
4
4
|
import type { Op } from "./op.ts";
|
|
5
|
-
import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt } from "./resource.ts";
|
|
5
|
+
import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt, withFrom } from "./resource.ts";
|
|
6
6
|
import type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
7
7
|
import { type DRIFT_WORKFLOW, errorMessage } from "./shared.ts";
|
|
8
8
|
import { SOURCE_RESOLVE, type sourceOps } from "./source.ts";
|
|
@@ -128,8 +128,10 @@ export const observed = (out: { gone: boolean; state?: State }): Found =>
|
|
|
128
128
|
* Each declared resource against what its import `found`: `error`, `absent` (unbound, with the
|
|
129
129
|
* binding id of what its identity found as `found`), `gone`, else the state and the declared
|
|
130
130
|
* fields compared by `diffDeclared`, a reference against the state of the resource it names
|
|
131
|
-
* (so GitHub's node id for a repository counts as its name)
|
|
132
|
-
*
|
|
131
|
+
* (so GitHub's node id for a repository counts as its name), and a `from` reference's field
|
|
132
|
+
* against that resource's state at its path, left out while that resource is absent, gone or
|
|
133
|
+
* unreadable (its own row says so). A host with a `revisions` entry also compares its revision
|
|
134
|
+
* against the commit its ref names now (or is that entry's `error`).
|
|
133
135
|
*/
|
|
134
136
|
export function compare(
|
|
135
137
|
types: ReadonlyMap<string, Resource>,
|
|
@@ -137,6 +139,12 @@ export function compare(
|
|
|
137
139
|
found: ReadonlyMap<string, Found>,
|
|
138
140
|
revisions: ReadonlyMap<string, Revision> = new Map(),
|
|
139
141
|
): DriftResult[] {
|
|
142
|
+
const bound = new Set(resources.filter((r) => r.binding !== undefined).map((r) => r.id));
|
|
143
|
+
// A resource's state as a `from` reference takes it: only a bound one's, read in this run.
|
|
144
|
+
const stateOf = (target: string) => {
|
|
145
|
+
const now = found.get(target);
|
|
146
|
+
return bound.has(target) && now && "state" in now ? now.state : undefined;
|
|
147
|
+
};
|
|
140
148
|
return resources.map(({ id, vendor, type, name, binding, desired, refs }): DriftResult => {
|
|
141
149
|
const row = { id, type, vendor, name, ...(binding !== undefined && { binding }) };
|
|
142
150
|
const now = found.get(id)!;
|
|
@@ -158,7 +166,8 @@ export function compare(
|
|
|
158
166
|
const named = found.get(target);
|
|
159
167
|
if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
|
|
160
168
|
}
|
|
161
|
-
const
|
|
169
|
+
const declared = withFrom(resource.fields.references, desired, refs, stateOf, "drop").desired;
|
|
170
|
+
const fields = diffDeclared(resource, now.state, declared, accept);
|
|
162
171
|
const resolved = revisions.get(id);
|
|
163
172
|
if (resolved && "error" in resolved) return { ...row, status: "error", fields: [], error: resolved.error };
|
|
164
173
|
// A host whose state does not say which commit it runs is not compared.
|
package/src/host.ts
CHANGED
|
@@ -46,8 +46,11 @@ export interface HostSpec {
|
|
|
46
46
|
source: string;
|
|
47
47
|
/** The branch (or tag) it runs: `main`, never a commit. */
|
|
48
48
|
ref: string;
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The secrets it passes the runtime (`SANOMA_DATABASE_URL`, `SANOMA_SECRETS_KEY`): a write-only
|
|
51
|
+
* field. Absent for a host that binds its own bootstrap (`bindsBootstrap`).
|
|
52
|
+
*/
|
|
53
|
+
secrets?: string;
|
|
51
54
|
/** The port the app listens on. */
|
|
52
55
|
port: string;
|
|
53
56
|
/** A public site's domain, by site name: the app serves each site at the root of its domain (`SANOMA_DOMAINS`). */
|
|
@@ -58,6 +61,11 @@ export interface HostSpec {
|
|
|
58
61
|
* file may not declare it, so a merge to `ref` is a change to the host.
|
|
59
62
|
*/
|
|
60
63
|
revision: string;
|
|
64
|
+
/**
|
|
65
|
+
* True for a host whose vendor mints the instance's bootstrap (its database URL, keys and
|
|
66
|
+
* tokens) and binds it itself, as Sanoma Cloud's does: such a type takes no `secrets`.
|
|
67
|
+
*/
|
|
68
|
+
bindsBootstrap?: boolean;
|
|
61
69
|
outputs: (state: Record<string, unknown>) => HostOutputs;
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -83,8 +91,9 @@ function unwrap(schema: z.ZodType): z.ZodType {
|
|
|
83
91
|
|
|
84
92
|
/**
|
|
85
93
|
* Throws what is wrong with a resource type's `host`: an input or `revision` that names no field,
|
|
86
|
-
* `secrets` that is not write-only,
|
|
87
|
-
*
|
|
94
|
+
* `secrets` that is not write-only, missing (unless the host binds its own bootstrap) or given
|
|
95
|
+
* beside `bindsBootstrap`, a `revision` that is write-only, an unknown lifetime, a type apply
|
|
96
|
+
* cannot change (not `writable`).
|
|
88
97
|
*/
|
|
89
98
|
export function checkHost(
|
|
90
99
|
what: string,
|
|
@@ -96,10 +105,17 @@ export function checkHost(
|
|
|
96
105
|
if (!LIFETIMES.includes(host.capabilities?.lifetime)) {
|
|
97
106
|
problems.push(`capabilities.lifetime is one of ${LIFETIMES.join(", ")}`);
|
|
98
107
|
}
|
|
99
|
-
|
|
108
|
+
// An input given as undefined is one not given.
|
|
109
|
+
const given = Object.entries(host.inputs ?? {}).filter(([, path]) => path !== undefined);
|
|
110
|
+
for (const [input, path] of [...given, ["revision", host.revision] as const]) {
|
|
100
111
|
if (typeof path !== "string" || !hasPath(type.schema, path)) problems.push(`${input} names no field (${path})`);
|
|
101
112
|
}
|
|
102
|
-
|
|
113
|
+
const secrets = host.inputs?.secrets;
|
|
114
|
+
if (host.bindsBootstrap) {
|
|
115
|
+
if (secrets !== undefined) problems.push("inputs.secrets is not taken by a host that binds its own bootstrap");
|
|
116
|
+
} else if (secrets === undefined || !type.fields.writeOnly.includes(secrets)) {
|
|
117
|
+
problems.push("inputs.secrets must be a write-only field, unless the host binds its own bootstrap");
|
|
118
|
+
}
|
|
103
119
|
if (type.fields.writeOnly.includes(host.revision)) problems.push("revision must not be write-only: drift reads it");
|
|
104
120
|
if (typeof host.outputs !== "function") problems.push("outputs must be a function of the state");
|
|
105
121
|
if (problems.length) throw new Error(`${what}: its host: ${problems.join("; ")}`);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { IdentityEnrollment, IdentityEnrollments, IdentityAudit } from "./cloud-identity.ts";
|
|
3
|
+
|
|
4
|
+
const CREDENTIAL = /^[A-Za-z0-9_-]{43}$/;
|
|
5
|
+
const ACCOUNT = /^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/;
|
|
6
|
+
const credentialHash = (credential: string) => createHash("sha256").update(credential).digest("hex");
|
|
7
|
+
|
|
8
|
+
const validEnrollment = (e: IdentityEnrollment | undefined): e is IdentityEnrollment =>
|
|
9
|
+
!!e &&
|
|
10
|
+
[e.deployment, e.company, e.environment].every((v) => typeof v === "string" && v.length > 0 && v.length <= 256) &&
|
|
11
|
+
ACCOUNT.test(e.serviceAccount) &&
|
|
12
|
+
Number.isSafeInteger(e.generation) &&
|
|
13
|
+
e.generation > 0;
|
|
14
|
+
const reply = (status: number, body: unknown) =>
|
|
15
|
+
Response.json(body, {
|
|
16
|
+
status,
|
|
17
|
+
headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Node/edge adapters can represent an empty POST as a stream. Reject actual bytes,
|
|
21
|
+
// without buffering attacker-controlled input or waiting indefinitely for its end.
|
|
22
|
+
async function hasBody(request: Request): Promise<boolean> {
|
|
23
|
+
if (!request.body) return false;
|
|
24
|
+
const reader = request.body.getReader();
|
|
25
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
26
|
+
try {
|
|
27
|
+
const first = await Promise.race([
|
|
28
|
+
reader.read(),
|
|
29
|
+
new Promise<never>((_, reject) => {
|
|
30
|
+
timeout = setTimeout(() => reject(new Error("Invalid broker request")), 5_000);
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
return !first.done;
|
|
34
|
+
} finally {
|
|
35
|
+
clearTimeout(timeout);
|
|
36
|
+
void reader.cancel().catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shared server-only issuance boundary. Public factories fix the route, audience and token kind. */
|
|
41
|
+
export function enrollmentBroker<T extends { expiresAt: number }>(options: {
|
|
42
|
+
path: string;
|
|
43
|
+
method: "GET" | "POST";
|
|
44
|
+
enrollments: IdentityEnrollments;
|
|
45
|
+
accept(enrollment: IdentityEnrollment): boolean;
|
|
46
|
+
issue(enrollment: IdentityEnrollment): Promise<T>;
|
|
47
|
+
valid(token: T, now: number): boolean;
|
|
48
|
+
body(token: T): unknown;
|
|
49
|
+
audit(event: IdentityAudit): Promise<void>;
|
|
50
|
+
}): (request: Request) => Promise<Response> {
|
|
51
|
+
return async (request) => {
|
|
52
|
+
let enrollment: IdentityEnrollment | undefined;
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(request.url);
|
|
55
|
+
if (url.pathname !== options.path || request.method !== options.method) return reply(404, { error: "not_found" });
|
|
56
|
+
if (url.search || (await hasBody(request))) return reply(400, { error: "invalid_request" });
|
|
57
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
58
|
+
const credential = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
|
|
59
|
+
const hash = CREDENTIAL.test(credential) ? credentialHash(credential) : undefined;
|
|
60
|
+
enrollment = hash ? await options.enrollments.active(hash) : undefined;
|
|
61
|
+
if (!validEnrollment(enrollment) || !options.accept(enrollment)) {
|
|
62
|
+
await options.audit({ result: "denied" });
|
|
63
|
+
return reply(401, { error: "unauthorized" });
|
|
64
|
+
}
|
|
65
|
+
// Copy authoritative values before any awaits; caller fields never select a target or lifetime.
|
|
66
|
+
enrollment = {
|
|
67
|
+
deployment: enrollment.deployment,
|
|
68
|
+
company: enrollment.company,
|
|
69
|
+
environment: enrollment.environment,
|
|
70
|
+
serviceAccount: enrollment.serviceAccount,
|
|
71
|
+
generation: enrollment.generation,
|
|
72
|
+
};
|
|
73
|
+
const token = await options.issue(enrollment);
|
|
74
|
+
if (!options.valid(token, Date.now())) throw new Error("Invalid issued token");
|
|
75
|
+
const current = await options.enrollments.active(hash!);
|
|
76
|
+
if (
|
|
77
|
+
!current ||
|
|
78
|
+
(Object.keys(enrollment) as (keyof IdentityEnrollment)[]).some((key) => current[key] !== enrollment![key])
|
|
79
|
+
) {
|
|
80
|
+
await options.audit({ result: "denied", enrollment });
|
|
81
|
+
return reply(401, { error: "unauthorized" });
|
|
82
|
+
}
|
|
83
|
+
await options.audit({ result: "issued", enrollment, expiresAt: token.expiresAt });
|
|
84
|
+
return reply(200, options.body(token));
|
|
85
|
+
} catch {
|
|
86
|
+
try {
|
|
87
|
+
await options.audit({ result: "unavailable", ...(enrollment && { enrollment }) });
|
|
88
|
+
} catch {
|
|
89
|
+
/* fail closed */
|
|
90
|
+
}
|
|
91
|
+
return reply(503, { error: "unavailable" });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { Pool, PoolClient } from "pg";
|
|
2
|
+
import type { IdentityEnrollment } from "./cloud-identity.ts";
|
|
3
|
+
|
|
4
|
+
export type DeploymentIdentity = Omit<IdentityEnrollment, "generation">;
|
|
5
|
+
export interface EnrollIdentity extends IdentityEnrollment {
|
|
6
|
+
hash: string;
|
|
7
|
+
validUntil: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Provisioning authority only. The serving broker receives the separate read-only interface. */
|
|
11
|
+
export interface IdentityProvisioning {
|
|
12
|
+
enroll(input: EnrollIdentity): Promise<void>;
|
|
13
|
+
retire(input: DeploymentIdentity & { generation: number; validUntil: string }): Promise<void>;
|
|
14
|
+
revoke(input: DeploymentIdentity): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class IdentityProvisioningError extends Error {
|
|
18
|
+
readonly retryable: boolean;
|
|
19
|
+
readonly code: "invalid" | "conflict" | "unavailable";
|
|
20
|
+
constructor(code: "invalid" | "conflict" | "unavailable") {
|
|
21
|
+
super(`Identity provisioning: ${code}`);
|
|
22
|
+
this.name = "IdentityProvisioningError";
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.retryable = code === "unavailable";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Recognize another package copy without ever forwarding arbitrary messages or code strings. */
|
|
29
|
+
export function identityProvisioningCode(error: unknown): IdentityProvisioningError["code"] | undefined {
|
|
30
|
+
if (!error || typeof error !== "object" || !("name" in error) || error.name !== "IdentityProvisioningError") return;
|
|
31
|
+
if (!("code" in error)) return;
|
|
32
|
+
switch (error.code) {
|
|
33
|
+
case "invalid":
|
|
34
|
+
return "invalid";
|
|
35
|
+
case "conflict":
|
|
36
|
+
return "conflict";
|
|
37
|
+
case "unavailable":
|
|
38
|
+
return "unavailable";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fail(code: "invalid" | "conflict"): never {
|
|
43
|
+
throw new IdentityProvisioningError(code);
|
|
44
|
+
}
|
|
45
|
+
const sameIdentity = (a: DeploymentIdentity, b: DeploymentIdentity) =>
|
|
46
|
+
a.deployment === b.deployment &&
|
|
47
|
+
a.company === b.company &&
|
|
48
|
+
a.environment === b.environment &&
|
|
49
|
+
a.serviceAccount === b.serviceAccount;
|
|
50
|
+
const checkIdentity = (input: DeploymentIdentity) => {
|
|
51
|
+
if (
|
|
52
|
+
![input.deployment, input.company, input.environment].every(
|
|
53
|
+
(s) => typeof s === "string" && s.length > 0 && s.length <= 256 && !s.includes("\0"),
|
|
54
|
+
) ||
|
|
55
|
+
!/^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/.test(input.serviceAccount)
|
|
56
|
+
)
|
|
57
|
+
fail("invalid");
|
|
58
|
+
};
|
|
59
|
+
const generationOf = (generation: number) => {
|
|
60
|
+
if (!Number.isSafeInteger(generation) || generation <= 0) fail("invalid");
|
|
61
|
+
};
|
|
62
|
+
const deadlineOf = (value: string) => {
|
|
63
|
+
const time = Date.parse(value);
|
|
64
|
+
if (!Number.isFinite(time)) fail("invalid");
|
|
65
|
+
return time;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Caller owns a dedicated writer pool; never give this pool to the broker or company code. */
|
|
69
|
+
export function postgresIdentityProvisioning(
|
|
70
|
+
database: () => Promise<Pick<Pool, "connect">>,
|
|
71
|
+
options: { maxValidityMs: number },
|
|
72
|
+
): IdentityProvisioning {
|
|
73
|
+
const maximum = options.maxValidityMs;
|
|
74
|
+
if (!Number.isSafeInteger(maximum) || maximum <= 0) fail("invalid");
|
|
75
|
+
async function transaction(
|
|
76
|
+
input: DeploymentIdentity,
|
|
77
|
+
create: boolean,
|
|
78
|
+
run: (db: PoolClient, enabled: boolean, now: number) => Promise<void>,
|
|
79
|
+
) {
|
|
80
|
+
checkIdentity(input);
|
|
81
|
+
let db: PoolClient | undefined;
|
|
82
|
+
try {
|
|
83
|
+
db = await (await database()).connect();
|
|
84
|
+
await db.query("BEGIN");
|
|
85
|
+
if (create)
|
|
86
|
+
await db.query(
|
|
87
|
+
`INSERT INTO sanoma_identity.deployments (id, company, environment, service_account)
|
|
88
|
+
VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO NOTHING`,
|
|
89
|
+
[input.deployment, input.company, input.environment, input.serviceAccount],
|
|
90
|
+
);
|
|
91
|
+
// Every writer takes the deployment row lock: enrollment cannot race revocation/retirement.
|
|
92
|
+
const { rows } = await db.query<DeploymentIdentity & { enabled: boolean }>(
|
|
93
|
+
`SELECT id AS deployment, company, environment, service_account AS "serviceAccount", enabled
|
|
94
|
+
FROM sanoma_identity.deployments WHERE id=$1 FOR UPDATE`,
|
|
95
|
+
[input.deployment],
|
|
96
|
+
);
|
|
97
|
+
const row = rows[0];
|
|
98
|
+
if (!row || !sameIdentity(row, input)) fail("conflict");
|
|
99
|
+
const clock = await db.query<{ now: Date }>("SELECT clock_timestamp() AS now");
|
|
100
|
+
await run(db, row.enabled, clock.rows[0]!.now.getTime());
|
|
101
|
+
await db.query("COMMIT");
|
|
102
|
+
} catch (error) {
|
|
103
|
+
await db?.query("ROLLBACK").catch(() => {});
|
|
104
|
+
const code = identityProvisioningCode(error);
|
|
105
|
+
if (code) throw new IdentityProvisioningError(code);
|
|
106
|
+
if ((error as { code?: string })?.code === "23505") throw new IdentityProvisioningError("conflict");
|
|
107
|
+
throw new IdentityProvisioningError("unavailable");
|
|
108
|
+
} finally {
|
|
109
|
+
db?.release();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
async enroll(input) {
|
|
114
|
+
generationOf(input.generation);
|
|
115
|
+
const until = deadlineOf(input.validUntil);
|
|
116
|
+
if (!/^[0-9a-f]{64}$/.test(input.hash)) fail("invalid");
|
|
117
|
+
await transaction(input, true, async (db, enabled, now) => {
|
|
118
|
+
if (!enabled) fail("conflict");
|
|
119
|
+
if (until <= now || until - now > maximum) fail("invalid");
|
|
120
|
+
const { rows } = await db.query<{ generation: string; hash: string; valid_until: Date; revoked: boolean }>(
|
|
121
|
+
`SELECT generation, hash, valid_until, revoked FROM sanoma_identity.credentials
|
|
122
|
+
WHERE deployment=$1 ORDER BY generation DESC`,
|
|
123
|
+
[input.deployment],
|
|
124
|
+
);
|
|
125
|
+
const existing = rows.find((r) => Number(r.generation) === input.generation);
|
|
126
|
+
if (existing) {
|
|
127
|
+
// An exact retry is safe. Never extend a retired generation or revive a revoked one.
|
|
128
|
+
if (existing.hash !== input.hash || existing.revoked || existing.valid_until.getTime() !== until)
|
|
129
|
+
fail("conflict");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (rows[0] && Number(rows[0].generation) >= input.generation) fail("conflict");
|
|
133
|
+
await db.query(
|
|
134
|
+
`INSERT INTO sanoma_identity.credentials (hash, deployment, generation, valid_until)
|
|
135
|
+
VALUES ($1,$2,$3,$4)`,
|
|
136
|
+
[input.hash, input.deployment, input.generation, new Date(until)],
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
async retire(input) {
|
|
141
|
+
generationOf(input.generation);
|
|
142
|
+
const until = deadlineOf(input.validUntil);
|
|
143
|
+
await transaction(input, false, async (db) => {
|
|
144
|
+
// Retirement only shortens access. The predicate protects the schema's finite interval.
|
|
145
|
+
const result = await db.query(
|
|
146
|
+
`UPDATE sanoma_identity.credentials SET
|
|
147
|
+
revoked=revoked OR $3::timestamptz <= clock_timestamp(),
|
|
148
|
+
valid_until=CASE WHEN $3::timestamptz > valid_from THEN LEAST(valid_until,$3::timestamptz) ELSE valid_until END
|
|
149
|
+
WHERE deployment=$1 AND generation=$2`,
|
|
150
|
+
[input.deployment, input.generation, new Date(until)],
|
|
151
|
+
);
|
|
152
|
+
if (!result.rowCount) fail("conflict");
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
async revoke(input) {
|
|
156
|
+
await transaction(input, false, async (db) => {
|
|
157
|
+
// Terminal for this deployment id. A new enrollment cannot silently re-enable it.
|
|
158
|
+
await db.query("UPDATE sanoma_identity.deployments SET enabled=false WHERE id=$1", [input.deployment]);
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Run-local sandbox authority: never reads or mutates the live enrollment database. */
|
|
165
|
+
export function memoryIdentityProvisioning(): IdentityProvisioning {
|
|
166
|
+
const deployments = new Map<
|
|
167
|
+
string,
|
|
168
|
+
{ identity: DeploymentIdentity; enabled: boolean; credentials: Map<number, EnrollIdentity> }
|
|
169
|
+
>();
|
|
170
|
+
const hashes = new Set<string>();
|
|
171
|
+
const find = (input: DeploymentIdentity) => {
|
|
172
|
+
checkIdentity(input);
|
|
173
|
+
const found = deployments.get(input.deployment);
|
|
174
|
+
if (!found || !sameIdentity(found.identity, input)) return fail("conflict");
|
|
175
|
+
return found;
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
async enroll(input) {
|
|
179
|
+
checkIdentity(input);
|
|
180
|
+
generationOf(input.generation);
|
|
181
|
+
if (deadlineOf(input.validUntil) <= Date.now() || !/^[0-9a-f]{64}$/.test(input.hash)) fail("invalid");
|
|
182
|
+
const found = deployments.get(input.deployment);
|
|
183
|
+
if (found && (!sameIdentity(found.identity, input) || !found.enabled)) fail("conflict");
|
|
184
|
+
const current = found?.credentials.get(input.generation);
|
|
185
|
+
if (current) {
|
|
186
|
+
if (current.hash !== input.hash || deadlineOf(current.validUntil) !== deadlineOf(input.validUntil))
|
|
187
|
+
fail("conflict");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (hashes.has(input.hash) || (found && [...found.credentials.keys()].some((g) => g >= input.generation)))
|
|
191
|
+
fail("conflict");
|
|
192
|
+
const row = found ?? { identity: { ...input }, enabled: true, credentials: new Map<number, EnrollIdentity>() };
|
|
193
|
+
row.credentials.set(input.generation, { ...input });
|
|
194
|
+
hashes.add(input.hash);
|
|
195
|
+
deployments.set(input.deployment, row);
|
|
196
|
+
},
|
|
197
|
+
async retire(input) {
|
|
198
|
+
generationOf(input.generation);
|
|
199
|
+
const deadline = deadlineOf(input.validUntil);
|
|
200
|
+
const current = find(input).credentials.get(input.generation);
|
|
201
|
+
if (!current) return fail("conflict");
|
|
202
|
+
current.validUntil = new Date(Math.min(deadlineOf(current.validUntil), deadline)).toISOString();
|
|
203
|
+
},
|
|
204
|
+
async revoke(input) {
|
|
205
|
+
find(input).enabled = false;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import { Pool } from "pg";
|
|
4
|
+
import { NodeRequest, sendNodeResponse } from "srvx/node";
|
|
5
|
+
import {
|
|
6
|
+
identityBroker,
|
|
7
|
+
postgresIdentityEnrollments,
|
|
8
|
+
attachedGoogleTokenIssuer,
|
|
9
|
+
provisioningIdentityBroker,
|
|
10
|
+
attachedGoogleIdTokenIssuer,
|
|
11
|
+
type ProvisioningIdentity,
|
|
12
|
+
type IdentityAudit,
|
|
13
|
+
} from "./cloud-identity.ts";
|
|
14
|
+
|
|
15
|
+
/** Install with administrative authority in the audit database, separately from the serving process. */
|
|
16
|
+
export const CLOUD_IDENTITY_AUDIT_SQL = `
|
|
17
|
+
CREATE SCHEMA IF NOT EXISTS sanoma_identity;
|
|
18
|
+
REVOKE ALL ON SCHEMA sanoma_identity FROM PUBLIC;
|
|
19
|
+
CREATE TABLE IF NOT EXISTS sanoma_identity.audit_events (
|
|
20
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
21
|
+
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
22
|
+
result text NOT NULL CHECK (result IN ('issued', 'denied', 'unavailable')),
|
|
23
|
+
deployment text, company text, environment text, service_account text,
|
|
24
|
+
generation bigint, expires_at timestamptz
|
|
25
|
+
);
|
|
26
|
+
REVOKE ALL ON sanoma_identity.audit_events FROM PUBLIC;
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
export interface IdentityServiceOptions {
|
|
30
|
+
/** Explicit broker database login with SELECT only on the enrollment tables. */
|
|
31
|
+
enrollmentDatabaseUrl: string;
|
|
32
|
+
/** Explicit audit database login with INSERT only on audit_events. */
|
|
33
|
+
auditDatabaseUrl: string;
|
|
34
|
+
mode: { type: "runtime" } | { type: "provisioning"; identity: ProvisioningIdentity };
|
|
35
|
+
/** Cloud Run terminates TLS. Keep this service behind the deployment's HTTPS ingress. */
|
|
36
|
+
host?: string;
|
|
37
|
+
port?: number;
|
|
38
|
+
/** Bound in-flight issuance work and database waiters; excess requests receive 429. */
|
|
39
|
+
maxConcurrentRequests?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const unavailable = () => new Error("Identity service unavailable");
|
|
43
|
+
const poolFor = (connectionString: string) => {
|
|
44
|
+
if (!connectionString) throw unavailable();
|
|
45
|
+
const pool = new Pool({
|
|
46
|
+
connectionString,
|
|
47
|
+
max: 5,
|
|
48
|
+
connectionTimeoutMillis: 10_000,
|
|
49
|
+
query_timeout: 10_000,
|
|
50
|
+
statement_timeout: 10_000,
|
|
51
|
+
});
|
|
52
|
+
// Never forward database diagnostics: they can contain connection information.
|
|
53
|
+
pool.on("error", () => {});
|
|
54
|
+
return pool;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
async function checkRoles(reader: Pool, audit: Pool): Promise<void> {
|
|
58
|
+
const read = await reader.query<{ allowed: boolean }>(`SELECT
|
|
59
|
+
has_schema_privilege('sanoma_identity', 'USAGE') AND
|
|
60
|
+
NOT has_schema_privilege('sanoma_identity', 'CREATE') AND
|
|
61
|
+
NOT EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolcreaterole OR rolcreatedb OR rolbypassrls)) AND
|
|
62
|
+
has_table_privilege('sanoma_identity.deployments', 'SELECT') AND
|
|
63
|
+
has_table_privilege('sanoma_identity.credentials', 'SELECT') AND
|
|
64
|
+
NOT has_table_privilege('sanoma_identity.deployments', 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') AND
|
|
65
|
+
NOT has_table_privilege('sanoma_identity.credentials', 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') AS allowed`);
|
|
66
|
+
const write = await audit.query<{ allowed: boolean }>(`SELECT
|
|
67
|
+
has_schema_privilege('sanoma_identity', 'USAGE') AND
|
|
68
|
+
NOT has_schema_privilege('sanoma_identity', 'CREATE') AND
|
|
69
|
+
NOT EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND (rolsuper OR rolcreaterole OR rolcreatedb OR rolbypassrls)) AND
|
|
70
|
+
has_table_privilege('sanoma_identity.audit_events', 'INSERT') AND
|
|
71
|
+
NOT has_table_privilege('sanoma_identity.audit_events', 'SELECT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER') AS allowed`);
|
|
72
|
+
if (read.rows[0]?.allowed !== true || write.rows[0]?.allowed !== true) throw unavailable();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Starts a standalone broker without loading company code, starting a worker or installing schemas. */
|
|
76
|
+
export async function startIdentityService(options: IdentityServiceOptions): Promise<{
|
|
77
|
+
url: string;
|
|
78
|
+
stop(): Promise<void>;
|
|
79
|
+
}> {
|
|
80
|
+
let reader: Pool | undefined, audit: Pool | undefined;
|
|
81
|
+
const server = createServer();
|
|
82
|
+
let closing: Promise<void> | undefined;
|
|
83
|
+
const close = () =>
|
|
84
|
+
(closing ??= (async () => {
|
|
85
|
+
await new Promise<void>((resolve) => {
|
|
86
|
+
server.close(() => resolve());
|
|
87
|
+
server.closeAllConnections();
|
|
88
|
+
});
|
|
89
|
+
await Promise.all([reader?.end(), audit?.end()]);
|
|
90
|
+
})());
|
|
91
|
+
try {
|
|
92
|
+
const limit = options.maxConcurrentRequests ?? 32;
|
|
93
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 256) throw unavailable();
|
|
94
|
+
let active = 0;
|
|
95
|
+
reader = poolFor(options.enrollmentDatabaseUrl);
|
|
96
|
+
audit = poolFor(options.auditDatabaseUrl);
|
|
97
|
+
await checkRoles(reader, audit);
|
|
98
|
+
const record = async (event: IdentityAudit) => {
|
|
99
|
+
const e = event.enrollment;
|
|
100
|
+
// Fixed columns only: no tokens, hashes, headers or arbitrary caller fields.
|
|
101
|
+
await audit!.query(
|
|
102
|
+
`INSERT INTO sanoma_identity.audit_events
|
|
103
|
+
(result, deployment, company, environment, service_account, generation, expires_at)
|
|
104
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
|
105
|
+
[
|
|
106
|
+
event.result,
|
|
107
|
+
e?.deployment ?? null,
|
|
108
|
+
e?.company ?? null,
|
|
109
|
+
e?.environment ?? null,
|
|
110
|
+
e?.serviceAccount ?? null,
|
|
111
|
+
e?.generation ?? null,
|
|
112
|
+
event.expiresAt === undefined ? null : new Date(event.expiresAt),
|
|
113
|
+
],
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
const enrollments = postgresIdentityEnrollments(async () => reader!);
|
|
117
|
+
const handle =
|
|
118
|
+
options.mode.type === "runtime"
|
|
119
|
+
? identityBroker({ enrollments, issue: attachedGoogleTokenIssuer(), audit: record })
|
|
120
|
+
: options.mode.type === "provisioning"
|
|
121
|
+
? provisioningIdentityBroker({
|
|
122
|
+
identity: options.mode.identity,
|
|
123
|
+
enrollments,
|
|
124
|
+
issue: attachedGoogleIdTokenIssuer(options.mode.identity),
|
|
125
|
+
audit: record,
|
|
126
|
+
})
|
|
127
|
+
: (() => {
|
|
128
|
+
throw unavailable();
|
|
129
|
+
})();
|
|
130
|
+
server.requestTimeout = 15_000;
|
|
131
|
+
server.headersTimeout = 10_000;
|
|
132
|
+
server.on("request", (req, res) => {
|
|
133
|
+
// Fetch Request adapters may hide a GET body. Refuse it at the HTTP boundary.
|
|
134
|
+
if (
|
|
135
|
+
req.method === "GET" &&
|
|
136
|
+
(req.headers["transfer-encoding"] || (req.headers["content-length"] ?? "0") !== "0")
|
|
137
|
+
) {
|
|
138
|
+
res.writeHead(400, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
139
|
+
res.end('{"error":"invalid_request"}');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (req.url === "/healthz" && (req.method === "GET" || req.method === "HEAD")) {
|
|
143
|
+
res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
|
|
144
|
+
res.end("ok\n");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (active >= limit) {
|
|
148
|
+
res.writeHead(429, { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": "1" });
|
|
149
|
+
res.end('{"error":"busy"}');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
active++;
|
|
153
|
+
Promise.resolve()
|
|
154
|
+
.then(() => handle(new NodeRequest({ req, res })))
|
|
155
|
+
.then((response) => sendNodeResponse(res, response))
|
|
156
|
+
.catch(() => {
|
|
157
|
+
if (res.headersSent) res.destroy();
|
|
158
|
+
else {
|
|
159
|
+
res.writeHead(503, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
160
|
+
res.end('{"error":"unavailable"}');
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
.finally(() => {
|
|
164
|
+
active--;
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
await new Promise<void>((resolve, reject) => {
|
|
168
|
+
server.once("error", reject);
|
|
169
|
+
server.listen(options.port ?? 8080, options.host ?? "127.0.0.1", resolve);
|
|
170
|
+
});
|
|
171
|
+
const { address, family, port } = server.address() as AddressInfo;
|
|
172
|
+
const hostname =
|
|
173
|
+
address === "0.0.0.0" || address === "::" ? "localhost" : family === "IPv6" ? `[${address}]` : address;
|
|
174
|
+
return { url: `http://${hostname}:${port}`, stop: close };
|
|
175
|
+
} catch {
|
|
176
|
+
await close().catch(() => {});
|
|
177
|
+
throw unavailable();
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export {
|
|
2
2
|
type CallContext,
|
|
3
3
|
CLIENT_IP_HEADER,
|
|
4
|
+
requestOrigin,
|
|
5
|
+
sameOrigin,
|
|
4
6
|
type Connector,
|
|
5
7
|
type ConnectorSetup,
|
|
6
8
|
type ConnectorVendor,
|
|
@@ -12,13 +14,18 @@ export {
|
|
|
12
14
|
type Effect,
|
|
13
15
|
isOp,
|
|
14
16
|
isSvgElement,
|
|
17
|
+
INVITE_SECONDS,
|
|
15
18
|
type LoginContext,
|
|
16
19
|
type LoginHalf,
|
|
20
|
+
type LoginIdentity,
|
|
21
|
+
type LoginMethod,
|
|
17
22
|
type ModelContext,
|
|
18
23
|
type Op,
|
|
19
24
|
type OpSpec,
|
|
20
25
|
type ResourceGroup,
|
|
21
26
|
retryableStatus,
|
|
27
|
+
type SignInMethod,
|
|
28
|
+
type SocialClients,
|
|
22
29
|
type VendorInfo,
|
|
23
30
|
withSetup,
|
|
24
31
|
} from "./op.ts";
|
|
@@ -33,6 +40,7 @@ export {
|
|
|
33
40
|
type Builtin,
|
|
34
41
|
type Ctx,
|
|
35
42
|
type SleepRequest,
|
|
43
|
+
type StartedBy,
|
|
36
44
|
type StepOptions,
|
|
37
45
|
type Trigger,
|
|
38
46
|
type Use,
|
|
@@ -53,9 +61,11 @@ export {
|
|
|
53
61
|
type ResourceSpec,
|
|
54
62
|
withoutWriteOnly,
|
|
55
63
|
} from "./resource.ts";
|
|
64
|
+
export { type DnsRecord, dnsRecord } from "./dns.ts";
|
|
56
65
|
export {
|
|
57
66
|
declaredWakeup,
|
|
58
67
|
defineConfig,
|
|
68
|
+
envWakeup,
|
|
59
69
|
resolveConfig,
|
|
60
70
|
resolveDatabaseUrl,
|
|
61
71
|
type ResolvedConfig,
|
|
@@ -201,9 +211,16 @@ export {
|
|
|
201
211
|
storeDatabaseUrl,
|
|
202
212
|
} from "./database.ts";
|
|
203
213
|
export type { Chat, Chats } from "./chats.ts";
|
|
214
|
+
export type { Tokens } from "./tokens.ts";
|
|
215
|
+
export { type InviteRequest, openInvite, sealInvite, sealLoginCode } from "./seal.ts";
|
|
216
|
+
export { postInvite, sanoma } from "./runtime-ops.ts";
|
|
217
|
+
export { sourceKeyPair, sshPublicKey } from "./ssh-key.ts";
|
|
204
218
|
export {
|
|
205
219
|
checkWakeup,
|
|
206
220
|
type DeclaredWakeup,
|
|
221
|
+
type EnvWakeup,
|
|
222
|
+
HOST_URL,
|
|
223
|
+
WAKE_PATH,
|
|
207
224
|
type WakeupOutputs,
|
|
208
225
|
type WakeupRegistration,
|
|
209
226
|
type WakeupSpec,
|
|
@@ -243,6 +260,8 @@ export {
|
|
|
243
260
|
principalOf,
|
|
244
261
|
RUNTIME_AGENTS,
|
|
245
262
|
HOST_CHECK_AGENT,
|
|
263
|
+
LOGIN_AGENT,
|
|
264
|
+
LOGIN_CODE_WORKFLOW,
|
|
246
265
|
SCHEDULE_AGENT,
|
|
247
266
|
credentialReady,
|
|
248
267
|
DEFAULT_SLEEP_NAME,
|
|
@@ -298,7 +317,7 @@ export {
|
|
|
298
317
|
export { checkSecretStore, type SecretStoreCheck } from "./secret-store-check.ts";
|
|
299
318
|
|
|
300
319
|
export { postgresSecretStore } from "./credentials.ts";
|
|
301
|
-
export { deploymentKey } from "./deployment-key.ts";
|
|
320
|
+
export { deploymentKey, signingKeyFor } from "./deployment-key.ts";
|
|
302
321
|
|
|
303
322
|
export {
|
|
304
323
|
defineConnection,
|