@intx/db 0.3.0 → 0.4.0

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 (71) hide show
  1. package/README.md +11 -0
  2. package/dist/approval-store.d.ts +7 -0
  3. package/dist/approval-store.js +13 -0
  4. package/dist/backfill-principal-keys.d.ts +9 -0
  5. package/dist/backfill-principal-keys.js +51 -0
  6. package/dist/client.d.ts +27 -2
  7. package/dist/client.js +6 -0
  8. package/dist/config.d.ts +1 -0
  9. package/dist/config.js +2 -0
  10. package/dist/connection.js +1 -0
  11. package/dist/credential-resolution.d.ts +54 -5
  12. package/dist/credential-resolution.js +109 -0
  13. package/dist/index.d.ts +11 -5
  14. package/dist/index.js +10 -4
  15. package/dist/model-source-resolution.d.ts +6 -5
  16. package/dist/model-source-resolution.js +42 -13
  17. package/dist/parse-row.d.ts +39 -8
  18. package/dist/parse-row.js +8 -0
  19. package/dist/principal-key-store.d.ts +51 -0
  20. package/dist/principal-key-store.js +82 -0
  21. package/dist/principal-store.d.ts +44 -0
  22. package/dist/principal-store.js +67 -0
  23. package/dist/schema/approvals.js +5 -0
  24. package/dist/schema/git-tokens.js +1 -1
  25. package/dist/schema/index.d.ts +2 -0
  26. package/dist/schema/index.js +2 -0
  27. package/dist/schema/principal-keys.d.ts +126 -0
  28. package/dist/schema/principal-keys.js +28 -0
  29. package/dist/schema/sidecar-allocation.d.ts +19 -40
  30. package/dist/schema/sidecar-allocation.js +4 -8
  31. package/dist/schema/sidecar.d.ts +0 -17
  32. package/dist/schema/sidecar.js +4 -12
  33. package/dist/schema/tenants.js +13 -3
  34. package/dist/schema/workflow-probe.d.ts +358 -0
  35. package/dist/schema/workflow-probe.js +51 -0
  36. package/dist/schema/workflow-run-dispatch.d.ts +17 -0
  37. package/dist/schema/workflow-run-dispatch.js +7 -0
  38. package/dist/schema/workflow-run.d.ts +27 -0
  39. package/dist/schema/workflow-run.js +8 -0
  40. package/dist/sender-key-resolver.d.ts +91 -0
  41. package/dist/sender-key-resolver.js +184 -0
  42. package/dist/sidecar-allocation-store.d.ts +38 -3
  43. package/dist/sidecar-allocation-store.js +224 -22
  44. package/dist/signer-identity.d.ts +12 -0
  45. package/dist/signer-identity.js +15 -0
  46. package/dist/tenant-hierarchy.d.ts +2 -0
  47. package/dist/tenant-hierarchy.js +27 -0
  48. package/dist/workflow-probe-store.d.ts +47 -0
  49. package/dist/workflow-probe-store.js +124 -0
  50. package/dist/workflow-run-dispatch-store.d.ts +3 -0
  51. package/dist/workflow-run-dispatch-store.js +12 -2
  52. package/migrations/0085_add_approval_run_idx.sql +1 -0
  53. package/migrations/0086_cool_human_cannonball.sql +1 -0
  54. package/migrations/0087_drop_sidecar_placement.sql +3 -0
  55. package/migrations/0088_thick_sprite.sql +32 -0
  56. package/migrations/0089_tense_selene.sql +13 -0
  57. package/migrations/0090_tenant_domain_lower_unique.sql +2 -0
  58. package/migrations/0091_workflow_run_dispatch_sender_address.sql +21 -0
  59. package/migrations/0092_sidecar_destroy_failed.sql +4 -0
  60. package/migrations/0093_sidecar_initialization.sql +2 -0
  61. package/migrations/meta/0085_snapshot.json +4111 -0
  62. package/migrations/meta/0086_snapshot.json +4117 -0
  63. package/migrations/meta/0087_snapshot.json +4100 -0
  64. package/migrations/meta/0088_snapshot.json +4286 -0
  65. package/migrations/meta/0089_snapshot.json +4376 -0
  66. package/migrations/meta/0090_snapshot.json +4387 -0
  67. package/migrations/meta/0091_snapshot.json +4397 -0
  68. package/migrations/meta/0092_snapshot.json +4397 -0
  69. package/migrations/meta/0093_snapshot.json +4407 -0
  70. package/migrations/meta/_journal.json +63 -0
  71. package/package.json +6 -5
@@ -0,0 +1,184 @@
1
+ import { and, eq, isNotNull, or, sql } from "drizzle-orm";
2
+ import { parseAddress } from "@intx/types";
3
+ import { getLogger } from "@intx/log";
4
+ import { principal } from "./schema/principals.js";
5
+ import { tenant } from "./schema/tenants.js";
6
+ import { workflowRun } from "./schema/workflow-run.js";
7
+ const RUN_PREFIX = "run_";
8
+ const logger = getLogger(["db", "sender-key-resolver"]);
9
+ /**
10
+ * Resolve the durable public key a signed mail sender must verify against,
11
+ * unioning the two existing durable-key sources: a run address
12
+ * (`run_<id>@<domain>`) resolves to the run's `workflow_run.public_key`; any
13
+ * other address (`<refId>@<domain>`) is treated as a user sender and resolves
14
+ * to that principal's hub-custodied key.
15
+ *
16
+ * Read-only. Returns `null` when the sender has no durable key to resolve -- a
17
+ * malformed address, an unknown run, a run whose deploy has not been acked yet,
18
+ * or an address that matches no user principal. A `null` is a legitimate
19
+ * "unresolvable sender" answer the caller acts on; it is NOT an error.
20
+ *
21
+ * Addresses are matched case-insensitively. Inbound `From` addresses are
22
+ * lowercased when parsed (see `@intx/mime` `extractAddrSpec`), so the address is
23
+ * normalized here and stored values are compared under `lower(...)`. Tenant
24
+ * domains are `lower(domain)`-unique (`tenant_domain_lower_idx`), so a
25
+ * normalized domain matches at most one tenant. A user `refId` is a
26
+ * case-sensitively-unique betterAuth id that can carry uppercase; lowercasing it
27
+ * to reconcile with the parser is lossy, so the resolver prefers the exact
28
+ * (already-lowercase) row and falls back to a case-insensitive match to find a
29
+ * mixed-case-stored refId. When two principals in one tenant hold case-variant
30
+ * refIds the case-insensitive match is not unique and no exact row disambiguates
31
+ * it: the resolver throws rather than silently return one, which would attribute
32
+ * the sender to the wrong principal.
33
+ */
34
+ export async function resolveSenderKey(db, principalKeyStore, address) {
35
+ const normalized = address.toLowerCase();
36
+ const parsed = parseAddress(normalized);
37
+ if (parsed === null)
38
+ return null;
39
+ const { localPart, domain } = parsed;
40
+ if (localPart.startsWith(RUN_PREFIX)) {
41
+ // A run sender resolves to the sidecar-minted key the hub recorded on the
42
+ // deployment anchor at `agent.deploy.ack`. `workflow_run.address` is unique
43
+ // among the runs that set it, so there is at most one row. A row whose
44
+ // `public_key` is still null is a deployed-but-not-yet-acked run -- the
45
+ // expected pre-ack state -- so it resolves to nothing rather than erroring,
46
+ // unlike the keyless-principal invariant break below.
47
+ const [row] = await db
48
+ .select({ publicKey: workflowRun.publicKey })
49
+ .from(workflowRun)
50
+ .where(eq(sql `lower(${workflowRun.address})`, normalized))
51
+ .limit(1);
52
+ if (row === undefined || row.publicKey === null)
53
+ return null;
54
+ return { source: "run", publicKey: row.publicKey };
55
+ }
56
+ // A user sender resolves to its hub-custodied principal key, keyed by the
57
+ // `(tenant domain, user refId)` its From address carries.
58
+ const principalId = await resolveUserPrincipalId(db, domain, localPart);
59
+ if (principalId === null)
60
+ return null;
61
+ // The principal exists; a principal with no active key violates INTR-164's
62
+ // invariant that every principal is minted with one, so `getPublicKey` throws
63
+ // rather than defaulting. Do not soften that to null -- unlike the pre-ack run
64
+ // key above, a keyless principal is a real breakage that must surface.
65
+ const publicKey = await principalKeyStore.getPublicKey(principalId, db);
66
+ return { source: "user", publicKey };
67
+ }
68
+ /**
69
+ * Resolve the hex-encoded public key to stamp on an outbound mail frame, as a
70
+ * BEST-EFFORT value that never blocks delivery. Returns the key, or `null` when
71
+ * the sender has no resolvable key -- OR when resolution FAILS.
72
+ *
73
+ * {@link resolveSenderKey} throws on a genuine fault (an ambiguous user address,
74
+ * or a principal with no active key -- an INTR-164 invariant break). Those
75
+ * throws must fail loud for {@link auditSenderKeys}, but they must not break
76
+ * the send path: the frame key is nullable, so a recipient with no co-delivered
77
+ * key resolves the sender as `unknown`, which its admission policy rejects by
78
+ * default (a workflow may relax `unknown` to admit).
79
+ * Coupling delivery to key resolution would let a data-integrity fault strand a
80
+ * run or drop mail. So a throw here degrades to `null` and is logged at ERROR
81
+ * with its cause -- a degraded fault, kept distinct from the ordinary
82
+ * unresolvable-sender `null`, which stays silent.
83
+ *
84
+ * The principal key store is real in production (INTR-164 mints every principal
85
+ * a key), so the throw path is a defensive safety net, not the common case: the
86
+ * normal outcome is a resolved, populated key.
87
+ */
88
+ export async function resolveFrameSenderKey(db, principalKeyStore, address) {
89
+ try {
90
+ return ((await resolveSenderKey(db, principalKeyStore, address))?.publicKey ??
91
+ null);
92
+ }
93
+ catch (cause) {
94
+ logger.error `Degraded to a null frame sender key for ${address}: resolving its public key failed (a fault, not an unresolvable sender): ${cause instanceof Error ? cause.message : String(cause)}`;
95
+ return null;
96
+ }
97
+ }
98
+ /**
99
+ * Resolve the user principal a normalized `<localPart>@<domain>` address names,
100
+ * or `null` when none matches. `lower(domain)` is unique, so the domain selects
101
+ * at most one tenant; within it, prefer the principal whose `refId` is exactly
102
+ * the normalized localPart (the canonical lowercase refId). The domain is
103
+ * matched case-insensitively in both queries because an existing tenant's stored
104
+ * domain may be mixed-case (creation lowercases new ones, but legacy rows are
105
+ * left as stored). Only when no exact refId exists does it fall back to a
106
+ * case-insensitive refId match, which finds a mixed-case-stored refId. A
107
+ * non-unique case-insensitive match with no exact row means two principals in
108
+ * the tenant hold case-variant refIds; that is genuinely ambiguous, so it throws
109
+ * rather than return an arbitrary one and attribute the sender to the wrong
110
+ * principal.
111
+ */
112
+ async function resolveUserPrincipalId(db, domain, localPart) {
113
+ const [exact] = await db
114
+ .select({ principalId: principal.id })
115
+ .from(principal)
116
+ .innerJoin(tenant, eq(principal.tenantId, tenant.id))
117
+ .where(and(eq(sql `lower(${tenant.domain})`, domain), eq(principal.kind, "user"), eq(principal.refId, localPart)))
118
+ .limit(1);
119
+ if (exact !== undefined)
120
+ return exact.principalId;
121
+ const caseInsensitive = await db
122
+ .select({ principalId: principal.id })
123
+ .from(principal)
124
+ .innerJoin(tenant, eq(principal.tenantId, tenant.id))
125
+ .where(and(eq(sql `lower(${tenant.domain})`, domain), eq(principal.kind, "user"), eq(sql `lower(${principal.refId})`, localPart)))
126
+ .limit(2);
127
+ if (caseInsensitive.length === 0)
128
+ return null;
129
+ if (caseInsensitive.length > 1) {
130
+ throw new Error(`resolveSenderKey: user sender ${localPart}@${domain} is ambiguous; it ` +
131
+ `matches multiple principals with case-variant refIds and no canonical ` +
132
+ `row -- the colliding principal refIds must be reconciled`);
133
+ }
134
+ const [only] = caseInsensitive;
135
+ return only === undefined ? null : only.principalId;
136
+ }
137
+ /**
138
+ * Sweep every mail sender that could sign and confirm each resolves to a durable
139
+ * public key via {@link resolveSenderKey}. Read-only.
140
+ *
141
+ * The senders that can sign are every workflow run that holds a signing key or
142
+ * is live ("running") and carries a sending address, plus every active user
143
+ * principal. A never-signed run -- deployed-but-not-yet-acked, or failed or
144
+ * cancelled before ack (address set, key null) -- is excluded. Any checked
145
+ * sender that does not resolve is collected in `unresolved`. A keyless user
146
+ * principal is an INTR-164 invariant break, so `resolveSenderKey` throws and the
147
+ * sweep fails loudly rather than folding it into `unresolved` -- it is a
148
+ * different, more serious fault than an unresolvable address.
149
+ */
150
+ export async function auditSenderKeys(db, principalKeyStore) {
151
+ const unresolved = [];
152
+ const runs = await db
153
+ .select({ address: workflowRun.address })
154
+ .from(workflowRun)
155
+ .where(and(isNotNull(workflowRun.address),
156
+ // A run can only have signed if it holds a key, so every key-null row is
157
+ // a non-signer EXCEPT a live "running" run, which is the genuine
158
+ // can-sign-but-keyless hole to flag. This includes all key-bearing rows
159
+ // (any status -- a terminal run's in-flight mail is still verifiable)
160
+ // and excludes both the pre-ack "deployed" window and a run that failed
161
+ // or was cancelled before ack (address set, key null, never signed).
162
+ // Match "running" literally, NOT isLiveWorkflowRunStatus, which also
163
+ // admits "deployed" and would re-open the pre-ack false positive.
164
+ or(isNotNull(workflowRun.publicKey), eq(workflowRun.status, "running"))));
165
+ for (const run of runs) {
166
+ if (run.address === null)
167
+ continue;
168
+ if ((await resolveSenderKey(db, principalKeyStore, run.address)) === null) {
169
+ unresolved.push({ address: run.address, kind: "run" });
170
+ }
171
+ }
172
+ const users = await db
173
+ .select({ refId: principal.refId, domain: tenant.domain })
174
+ .from(principal)
175
+ .innerJoin(tenant, eq(principal.tenantId, tenant.id))
176
+ .where(and(eq(principal.kind, "user"), eq(principal.status, "active")));
177
+ for (const user of users) {
178
+ const address = `${user.refId}@${user.domain}`;
179
+ if ((await resolveSenderKey(db, principalKeyStore, address)) === null) {
180
+ unresolved.push({ address, kind: "user" });
181
+ }
182
+ }
183
+ return { runsChecked: runs.length, usersChecked: users.length, unresolved };
184
+ }
@@ -1,5 +1,6 @@
1
- import { SidecarPlacementRequirement, type SidecarAllocationStatus } from "@intx/types";
1
+ import { type SidecarAllocationStatus } from "@intx/types";
2
2
  import type { DB, DBExecutor } from "./client.js";
3
+ import { type WorkflowRunCredentialRefs } from "./schema/index.js";
3
4
  type DBHandle = DB["db"];
4
5
  declare const activeStatuses: readonly ["pending", "provisioning", "allocated", "replacing", "releasing"];
5
6
  export type SidecarAllocation = {
@@ -10,7 +11,6 @@ export type SidecarAllocation = {
10
11
  readonly provisionerApiVersion: 1;
11
12
  readonly provisionerBindingFingerprint: string;
12
13
  readonly sidecarId?: string;
13
- readonly placement: SidecarPlacementRequirement;
14
14
  readonly status: SidecarAllocationStatus;
15
15
  readonly generation: number;
16
16
  readonly ensureAcceptedGeneration?: number;
@@ -18,6 +18,8 @@ export type SidecarAllocation = {
18
18
  readonly nextAttemptAt?: Date;
19
19
  readonly reconciliationLeaseId?: string;
20
20
  readonly reconciliationLeaseExpiresAt?: Date;
21
+ /** Outstanding deploy attempt, retained after its reconciliation lease ends. */
22
+ readonly initializationLeaseId?: string;
21
23
  readonly ensureAttempts: number;
22
24
  readonly destroyAttempts: number;
23
25
  readonly connectDeadline?: Date;
@@ -33,10 +35,16 @@ export type CreatePendingSidecarAllocationArgs = {
33
35
  readonly provisionerId: string;
34
36
  readonly provisionerApiVersion: 1;
35
37
  readonly provisionerBindingFingerprint: string;
36
- readonly placement?: SidecarPlacementRequirement;
37
38
  readonly now?: Date;
38
39
  };
40
+ export type CreateAdoptedSidecarAllocationArgs = CreatePendingSidecarAllocationArgs & {
41
+ readonly sidecarId: string;
42
+ readonly generation: number;
43
+ readonly externalRef?: string;
44
+ readonly connectDeadline: Date;
45
+ };
39
46
  export type ClaimSidecarAllocationArgs = {
47
+ readonly excludedAllocationIds?: readonly string[];
40
48
  readonly leaseId: string;
41
49
  readonly leaseDurationMs: number;
42
50
  };
@@ -90,6 +98,8 @@ export type BeginSidecarReplacementArgs = {
90
98
  readonly expectedStatus: "provisioning" | "allocated";
91
99
  readonly expectedGeneration: number;
92
100
  readonly expectedLeaseId: string;
101
+ readonly onlyIfInitializationIncomplete?: boolean;
102
+ readonly expectedInitializationLeaseId?: string;
93
103
  readonly nextAttemptAt: Date;
94
104
  readonly failureCode: string;
95
105
  readonly failureMessage: string;
@@ -102,12 +112,15 @@ export type BeginSidecarReleaseArgs = {
102
112
  readonly failureCode?: string;
103
113
  readonly failureMessage?: string;
104
114
  readonly expectedLeaseId?: string;
115
+ readonly expectedInitializationLeaseId?: string;
105
116
  readonly now?: Date;
106
117
  };
107
118
  export type BeginUnrecoverableSidecarReleaseArgs = {
108
119
  readonly allocationId: string;
109
120
  readonly expectedGeneration: number;
110
121
  readonly expectedLeaseId: string;
122
+ readonly onlyIfInitializationIncomplete?: boolean;
123
+ readonly expectedInitializationLeaseId?: string;
111
124
  readonly failureCode: string;
112
125
  readonly failureMessage: string;
113
126
  readonly now?: Date;
@@ -145,8 +158,28 @@ export type FailSidecarAllocationArgs = {
145
158
  readonly expectedLeaseId?: string;
146
159
  readonly now?: Date;
147
160
  };
161
+ export type MarkSidecarDestroyFailedArgs = Omit<FailSidecarAllocationArgs, "expectedStatus">;
162
+ type InitializationArgs = {
163
+ readonly allocationId: string;
164
+ readonly generation: number;
165
+ readonly anchorRunId: string;
166
+ readonly tenantId: string;
167
+ readonly leaseId: string;
168
+ readonly signal: AbortSignal;
169
+ };
148
170
  export declare function createSidecarAllocationStore(db: DBHandle): {
171
+ beginInitialization(args: InitializationArgs): Promise<{
172
+ previousPublicKey: string | null;
173
+ } | null>;
174
+ completeInitialization(args: InitializationArgs & {
175
+ readonly publicKey: string;
176
+ readonly credentialRefs?: WorkflowRunCredentialRefs;
177
+ }): Promise<boolean>;
178
+ clearUnsentInitialization(args: InitializationArgs & {
179
+ readonly previousPublicKey: string | null;
180
+ }): Promise<boolean>;
149
181
  createPending(args: CreatePendingSidecarAllocationArgs, tx?: DBExecutor): Promise<SidecarAllocation>;
182
+ createAdopted(args: CreateAdoptedSidecarAllocationArgs, tx?: DBExecutor): Promise<SidecarAllocation>;
150
183
  bindInitialSidecar(args: BindInitialSidecarArgs): Promise<SidecarAllocation | null>;
151
184
  bindReplacementSidecar(args: BindReplacementSidecarArgs): Promise<SidecarAllocation | null>;
152
185
  markAllocated(args: MarkSidecarAllocatedArgs): Promise<SidecarAllocation | null>;
@@ -155,11 +188,13 @@ export declare function createSidecarAllocationStore(db: DBHandle): {
155
188
  beginRelease: (args: BeginSidecarReleaseArgs, tx?: DBExecutor) => Promise<SidecarAllocation | null>;
156
189
  beginUnrecoverableRelease(args: BeginUnrecoverableSidecarReleaseArgs): Promise<SidecarAllocation | null>;
157
190
  markReleased(args: MarkSidecarReleasedArgs): Promise<SidecarAllocation | null>;
191
+ markDestroyFailed(args: MarkSidecarDestroyFailedArgs): Promise<SidecarAllocation | null>;
158
192
  failWithoutInfrastructure(args: FailSidecarAllocationArgs, tx?: DBExecutor): Promise<SidecarAllocation | null>;
159
193
  findById(id: string): Promise<SidecarAllocation | null>;
160
194
  findByAnchorRunId(anchorRunId: string): Promise<SidecarAllocation | null>;
161
195
  claimNextReconcilable(args: ClaimSidecarAllocationArgs): Promise<SidecarAllocation | null>;
162
196
  extendReconciliationLease(allocationId: string, leaseId: string, leaseDurationMs: number): Promise<boolean>;
197
+ isReconciliationLeaseCurrent(allocationId: string, generation: number, leaseId: string): Promise<boolean>;
163
198
  markConnectionReady(args: MarkSidecarConnectionReadyArgs): Promise<SidecarAllocation | null>;
164
199
  markConnectionLost(args: MarkSidecarConnectionLostArgs): Promise<SidecarAllocation | null>;
165
200
  scheduleReconnectIfUnscheduled(args: ScheduleSidecarReconnectIfUnscheduledArgs): Promise<SidecarAllocation | null>;