@opengeni/db 0.23.0 → 0.26.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 (34) hide show
  1. package/dist/{chunk-3UCHDMKG.js → chunk-7WTDI7Y3.js} +109 -2
  2. package/dist/chunk-7WTDI7Y3.js.map +1 -0
  3. package/dist/{chunk-L6ADMZHE.js → chunk-KW6U54V2.js} +3 -1
  4. package/dist/chunk-KW6U54V2.js.map +1 -0
  5. package/dist/connection-token-resolver.d.ts +24 -2
  6. package/dist/index.d.ts +68 -2
  7. package/dist/index.js +1168 -336
  8. package/dist/index.js.map +1 -1
  9. package/dist/preference-registry.d.ts +14 -0
  10. package/dist/provision-roles.js +1 -1
  11. package/dist/runtime-posture.d.ts +2 -2
  12. package/dist/schema.d.ts +262 -4
  13. package/dist/schema.js +3 -1
  14. package/dist/session-realtime-mirror.d.ts +22 -1
  15. package/dist/workspace-instruction-policies-schema.d.ts +68 -0
  16. package/dist/workspace-instruction-policies.d.ts +53 -7
  17. package/drizzle/0165_document_authority_foundation.sql +259 -0
  18. package/drizzle/0166_connection_disconnect_idempotency.sql +49 -0
  19. package/drizzle/0167_document_index_replay_authority.sql +61 -0
  20. package/drizzle/0168_workspace_instruction_policy_operation_receipts.sql +44 -0
  21. package/package.json +3 -3
  22. package/src/connection-token-resolver.ts +79 -16
  23. package/src/index.ts +419 -23
  24. package/src/preference-registry.ts +103 -0
  25. package/src/runtime-posture.ts +2 -0
  26. package/src/schema.ts +86 -0
  27. package/src/session-control.ts +48 -1
  28. package/src/session-queue-commands.ts +45 -11
  29. package/src/session-realtime-mirror.ts +117 -1
  30. package/src/session-realtime.ts +49 -1
  31. package/src/workspace-instruction-policies-schema.ts +30 -0
  32. package/src/workspace-instruction-policies.ts +438 -24
  33. package/dist/chunk-3UCHDMKG.js.map +0 -1
  34. package/dist/chunk-L6ADMZHE.js.map +0 -1
@@ -0,0 +1,259 @@
1
+ -- deployment-mode: maintenance
2
+ -- Durable organization/workspace/personal authority for Documents and chunks.
3
+ -- Existing workspace-visible rows remain workspace authority. Existing private
4
+ -- rows remain personal authority, anchored to their original workspace and
5
+ -- immutable creating subject. Collections/bases are not authority boundaries.
6
+
7
+ SET lock_timeout = '5s';
8
+ SET statement_timeout = '10min';
9
+
10
+ -- Reject a mixed-version cutover before taking table locks. Repeat after the
11
+ -- locks to close the connect-before-lock race. Deployment still owns the
12
+ -- external stop/no-restart protocol after this migration commits.
13
+ DO $document_writer_drain_before_lock$
14
+ BEGIN
15
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
16
+ AND EXISTS (
17
+ SELECT 1
18
+ FROM pg_stat_activity
19
+ WHERE datname = current_database()
20
+ AND usename = 'opengeni_app'
21
+ AND pid <> pg_backend_pid()
22
+ )
23
+ THEN
24
+ RAISE EXCEPTION 'document authority activation requires all opengeni_app sessions to be stopped'
25
+ USING ERRCODE = '55000';
26
+ END IF;
27
+ END
28
+ $document_writer_drain_before_lock$;
29
+
30
+ -- Both tables already use FORCE RLS. The migration role owns them but may not
31
+ -- be a superuser, so temporarily restore the ordinary owner bypass inside this
32
+ -- transaction. Runtime roles remain subject to RLS throughout the cutover.
33
+ -- The final policy block restores FORCE before this migration can commit.
34
+ ALTER TABLE "documents" NO FORCE ROW LEVEL SECURITY;
35
+ ALTER TABLE "document_chunks" NO FORCE ROW LEVEL SECURITY;
36
+
37
+ DO $document_writer_drain_after_lock$
38
+ BEGIN
39
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app')
40
+ AND EXISTS (
41
+ SELECT 1
42
+ FROM pg_stat_activity
43
+ WHERE datname = current_database()
44
+ AND usename = 'opengeni_app'
45
+ AND pid <> pg_backend_pid()
46
+ )
47
+ THEN
48
+ RAISE EXCEPTION 'document authority activation requires all opengeni_app sessions to be stopped'
49
+ USING ERRCODE = '55000';
50
+ END IF;
51
+ END
52
+ $document_writer_drain_after_lock$;
53
+
54
+ -- This is a protocol cutover, not a replay adapter. Stop the API and every
55
+ -- worker, wait for all document-index Temporal workflows to close, and settle
56
+ -- every queued/indexing document before applying the migration. The row check
57
+ -- is the database-verifiable half of that drain and prevents a legacy
58
+ -- three-field activity payload from being resumed under the new authority
59
+ -- contract.
60
+ DO $document_index_drain$
61
+ BEGIN
62
+ IF EXISTS (
63
+ SELECT 1 FROM "documents" WHERE "status" IN ('queued', 'indexing')
64
+ ) THEN
65
+ RAISE EXCEPTION 'migration 0165 requires every queued/indexing document to settle before cutover'
66
+ USING ERRCODE = '55000',
67
+ HINT = 'Stop API/workers, close document-index workflows, and retry after documents are ready or failed.';
68
+ END IF;
69
+ END
70
+ $document_index_drain$;
71
+
72
+ ALTER TABLE "documents" ADD COLUMN "authority_kind" text;
73
+ ALTER TABLE "documents" ADD COLUMN "authority_workspace_id" uuid;
74
+ ALTER TABLE "documents" ADD COLUMN "authority_subject_id" text;
75
+
76
+ ALTER TABLE "document_chunks" ADD COLUMN "authority_kind" text;
77
+ ALTER TABLE "document_chunks" ADD COLUMN "authority_workspace_id" uuid;
78
+ ALTER TABLE "document_chunks" ADD COLUMN "authority_subject_id" text;
79
+
80
+ -- Deterministic legacy backfill. Migration 0126 already guarantees every
81
+ -- private row has a non-empty creator, so this never widens an ambiguous row.
82
+ UPDATE "documents"
83
+ SET "authority_kind" = CASE WHEN "visibility" = 'private' THEN 'personal' ELSE 'workspace' END,
84
+ "authority_workspace_id" = "workspace_id",
85
+ "authority_subject_id" = CASE WHEN "visibility" = 'private' THEN "created_by" ELSE NULL END;
86
+
87
+ -- The document is the canonical parent for every chunk identity field. Repair
88
+ -- any legacy drift before freezing future writes with the trigger below.
89
+ UPDATE "document_chunks" AS chunk
90
+ SET "account_id" = document."account_id",
91
+ "workspace_id" = document."workspace_id",
92
+ "base_id" = document."base_id",
93
+ "file_id" = document."file_id"
94
+ FROM "documents" AS document
95
+ WHERE document."id" = chunk."document_id"
96
+ AND (
97
+ chunk."account_id" IS DISTINCT FROM document."account_id"
98
+ OR chunk."workspace_id" IS DISTINCT FROM document."workspace_id"
99
+ OR chunk."base_id" IS DISTINCT FROM document."base_id"
100
+ OR chunk."file_id" IS DISTINCT FROM document."file_id"
101
+ );
102
+
103
+ UPDATE "document_chunks" AS chunk
104
+ SET "authority_kind" = document."authority_kind",
105
+ "authority_workspace_id" = document."authority_workspace_id",
106
+ "authority_subject_id" = document."authority_subject_id"
107
+ FROM "documents" AS document
108
+ WHERE document."id" = chunk."document_id";
109
+
110
+ ALTER TABLE "documents" ALTER COLUMN "authority_kind" SET NOT NULL;
111
+ ALTER TABLE "documents" ALTER COLUMN "authority_kind" SET DEFAULT 'workspace';
112
+ ALTER TABLE "document_chunks" ALTER COLUMN "authority_kind" SET NOT NULL;
113
+ ALTER TABLE "document_chunks" ALTER COLUMN "authority_kind" SET DEFAULT 'workspace';
114
+
115
+ ALTER TABLE "documents"
116
+ ADD CONSTRAINT "documents_authority_workspace_fk"
117
+ FOREIGN KEY ("authority_workspace_id", "account_id")
118
+ REFERENCES "workspaces"("id", "account_id") ON DELETE RESTRICT;
119
+ ALTER TABLE "document_chunks"
120
+ ADD CONSTRAINT "document_chunks_authority_workspace_fk"
121
+ FOREIGN KEY ("authority_workspace_id", "account_id")
122
+ REFERENCES "workspaces"("id", "account_id") ON DELETE RESTRICT;
123
+
124
+ ALTER TABLE "documents" ADD CONSTRAINT "documents_authority_chk" CHECK (
125
+ ("authority_kind" = 'organization' AND "authority_workspace_id" IS NULL AND "authority_subject_id" IS NULL)
126
+ OR ("authority_kind" = 'workspace' AND "authority_workspace_id" = "workspace_id" AND "authority_subject_id" IS NULL)
127
+ OR (
128
+ "authority_kind" = 'personal'
129
+ AND "authority_workspace_id" = "workspace_id"
130
+ AND NULLIF(btrim("authority_subject_id"), '') IS NOT NULL
131
+ AND octet_length(convert_to("authority_subject_id", 'UTF8')) <= 1024
132
+ AND "authority_subject_id" = "created_by"
133
+ )
134
+ );
135
+ ALTER TABLE "documents" ADD CONSTRAINT "documents_authority_visibility_chk" CHECK (
136
+ ("authority_kind" = 'personal') = ("visibility" = 'private')
137
+ );
138
+ ALTER TABLE "document_chunks" ADD CONSTRAINT "document_chunks_authority_chk" CHECK (
139
+ ("authority_kind" = 'organization' AND "authority_workspace_id" IS NULL AND "authority_subject_id" IS NULL)
140
+ OR ("authority_kind" = 'workspace' AND "authority_workspace_id" = "workspace_id" AND "authority_subject_id" IS NULL)
141
+ OR (
142
+ "authority_kind" = 'personal'
143
+ AND "authority_workspace_id" = "workspace_id"
144
+ AND NULLIF(btrim("authority_subject_id"), '') IS NOT NULL
145
+ AND octet_length(convert_to("authority_subject_id", 'UTF8')) <= 1024
146
+ )
147
+ );
148
+
149
+ CREATE INDEX "documents_authority_idx" ON "documents" (
150
+ "account_id", "authority_kind", "authority_workspace_id", "authority_subject_id", "status"
151
+ );
152
+ CREATE INDEX "document_chunks_authority_idx" ON "document_chunks" (
153
+ "account_id", "authority_kind", "authority_workspace_id", "authority_subject_id"
154
+ );
155
+
156
+ CREATE OR REPLACE FUNCTION opengeni_private.apply_document_authority()
157
+ RETURNS trigger
158
+ LANGUAGE plpgsql
159
+ AS $$
160
+ BEGIN
161
+ IF TG_OP = 'UPDATE' AND (
162
+ NEW.authority_kind IS DISTINCT FROM OLD.authority_kind
163
+ OR NEW.authority_workspace_id IS DISTINCT FROM OLD.authority_workspace_id
164
+ OR NEW.authority_subject_id IS DISTINCT FROM OLD.authority_subject_id
165
+ OR NEW.created_by IS DISTINCT FROM OLD.created_by
166
+ OR NEW.visibility IS DISTINCT FROM OLD.visibility
167
+ ) THEN
168
+ RAISE EXCEPTION 'document authority is immutable';
169
+ END IF;
170
+
171
+ IF NEW.authority_kind IS NULL OR (
172
+ NEW.authority_kind = 'workspace'
173
+ AND NEW.authority_workspace_id IS NULL
174
+ AND NEW.visibility = 'private'
175
+ ) THEN
176
+ NEW.authority_kind := CASE WHEN NEW.visibility = 'private' THEN 'personal' ELSE 'workspace' END;
177
+ END IF;
178
+ CASE NEW.authority_kind
179
+ WHEN 'organization' THEN
180
+ NEW.authority_workspace_id := NULL;
181
+ NEW.authority_subject_id := NULL;
182
+ NEW.visibility := 'workspace';
183
+ WHEN 'workspace' THEN
184
+ NEW.authority_workspace_id := NEW.workspace_id;
185
+ NEW.authority_subject_id := NULL;
186
+ NEW.visibility := 'workspace';
187
+ WHEN 'personal' THEN
188
+ NEW.authority_workspace_id := NEW.workspace_id;
189
+ NEW.authority_subject_id := coalesce(NULLIF(btrim(NEW.authority_subject_id), ''), NULLIF(btrim(NEW.created_by), ''));
190
+ NEW.visibility := 'private';
191
+ ELSE
192
+ RAISE EXCEPTION 'invalid document authority kind: %', NEW.authority_kind;
193
+ END CASE;
194
+ RETURN NEW;
195
+ END
196
+ $$;
197
+
198
+ CREATE TRIGGER documents_authority_guard
199
+ BEFORE INSERT OR UPDATE ON "documents"
200
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.apply_document_authority();
201
+
202
+ CREATE OR REPLACE FUNCTION opengeni_private.apply_document_chunk_authority()
203
+ RETURNS trigger
204
+ LANGUAGE plpgsql
205
+ AS $$
206
+ DECLARE parent "documents"%ROWTYPE;
207
+ BEGIN
208
+ IF TG_OP = 'UPDATE' AND (
209
+ NEW.authority_kind IS DISTINCT FROM OLD.authority_kind
210
+ OR NEW.authority_workspace_id IS DISTINCT FROM OLD.authority_workspace_id
211
+ OR NEW.authority_subject_id IS DISTINCT FROM OLD.authority_subject_id
212
+ OR NEW.document_id IS DISTINCT FROM OLD.document_id
213
+ ) THEN
214
+ RAISE EXCEPTION 'document chunk authority is immutable';
215
+ END IF;
216
+ SELECT * INTO parent FROM "documents" WHERE "id" = NEW.document_id;
217
+ IF NOT FOUND
218
+ OR parent.account_id IS DISTINCT FROM NEW.account_id
219
+ OR parent.workspace_id IS DISTINCT FROM NEW.workspace_id
220
+ OR parent.base_id IS DISTINCT FROM NEW.base_id
221
+ OR parent.file_id IS DISTINCT FROM NEW.file_id
222
+ THEN
223
+ RAISE EXCEPTION 'document chunk parent identity mismatch';
224
+ END IF;
225
+ NEW.authority_kind := parent.authority_kind;
226
+ NEW.authority_workspace_id := parent.authority_workspace_id;
227
+ NEW.authority_subject_id := parent.authority_subject_id;
228
+ RETURN NEW;
229
+ END
230
+ $$;
231
+
232
+ CREATE TRIGGER document_chunks_authority_guard
233
+ BEFORE INSERT OR UPDATE ON "document_chunks"
234
+ FOR EACH ROW EXECUTE FUNCTION opengeni_private.apply_document_chunk_authority();
235
+
236
+ DO $$
237
+ DECLARE table_name text;
238
+ BEGIN
239
+ FOREACH table_name IN ARRAY ARRAY['documents', 'document_chunks'] LOOP
240
+ IF EXISTS (
241
+ SELECT 1 FROM pg_policies
242
+ WHERE schemaname = current_schema() AND tablename = table_name
243
+ AND policyname = 'workspace_isolation'
244
+ ) THEN
245
+ EXECUTE format('DROP POLICY workspace_isolation ON %I', table_name);
246
+ END IF;
247
+ EXECUTE format(
248
+ 'CREATE POLICY document_authority_isolation ON %I '
249
+ || 'USING (opengeni_private.scoped_knowledge_scope_visible('
250
+ || 'account_id, authority_kind, authority_workspace_id, authority_subject_id)) '
251
+ || 'WITH CHECK (opengeni_private.scoped_knowledge_scope_visible('
252
+ || 'account_id, authority_kind, authority_workspace_id, authority_subject_id))',
253
+ table_name
254
+ );
255
+ EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
256
+ EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
257
+ END LOOP;
258
+ END
259
+ $$;
@@ -0,0 +1,49 @@
1
+ -- deployment-mode: rolling
2
+ -- Bind a subject-owned connection disconnect to one caller-frozen generation
3
+ -- and operation key. Exact retries converge only while the produced generation
4
+ -- remains current; reconnecting permanently fences delayed old requests.
5
+
6
+ CREATE TABLE "connection_disconnect_operations" (
7
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
8
+ "account_id" uuid NOT NULL REFERENCES "managed_accounts"("id") ON DELETE CASCADE,
9
+ "workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE,
10
+ "connection_id" uuid NOT NULL REFERENCES "connections"("id") ON DELETE CASCADE,
11
+ "subject_id" text NOT NULL,
12
+ "idempotency_key" text NOT NULL,
13
+ "expected_version" integer NOT NULL,
14
+ "result_version" integer NOT NULL,
15
+ "created_at" timestamptz NOT NULL DEFAULT now(),
16
+ CONSTRAINT "connection_disconnect_operations_subject_key_uq"
17
+ UNIQUE ("workspace_id", "subject_id", "idempotency_key"),
18
+ CONSTRAINT "connection_disconnect_operations_connection_generation_uq"
19
+ UNIQUE ("workspace_id", "connection_id", "expected_version"),
20
+ CONSTRAINT "connection_disconnect_operations_identity_check"
21
+ CHECK (
22
+ length("subject_id") BETWEEN 1 AND 512
23
+ AND length("idempotency_key") BETWEEN 1 AND 200
24
+ AND "idempotency_key" = btrim("idempotency_key")
25
+ AND "expected_version" > 0
26
+ AND "result_version" = "expected_version" + 1
27
+ )
28
+ );
29
+
30
+ ALTER TABLE "connection_disconnect_operations" ENABLE ROW LEVEL SECURITY;
31
+ ALTER TABLE "connection_disconnect_operations" FORCE ROW LEVEL SECURITY;
32
+ CREATE POLICY workspace_subject_isolation ON "connection_disconnect_operations"
33
+ USING (
34
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
35
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
36
+ )
37
+ WITH CHECK (
38
+ opengeni_private.workspace_rls_visible("account_id", "workspace_id")
39
+ AND "subject_id" = nullif(current_setting('opengeni.subject_id', true), '')
40
+ );
41
+
42
+ DO $$
43
+ BEGIN
44
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
45
+ REVOKE ALL PRIVILEGES ON TABLE "connection_disconnect_operations" FROM opengeni_app;
46
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE "connection_disconnect_operations" TO opengeni_app;
47
+ END IF;
48
+ END
49
+ $$;
@@ -0,0 +1,61 @@
1
+ -- deployment-mode: rolling
2
+ -- Resolve only the immutable document authority tuple for historical
3
+ -- three-field document-index Temporal payloads. The ordinary documents policy
4
+ -- remains unchanged: personal content is still invisible without its exact
5
+ -- subject. This SECURITY DEFINER capability returns no content and manually
6
+ -- enforces the caller's already-applied account/workspace RLS context before
7
+ -- reading the exact document identity.
8
+
9
+ DO $document_index_authority_resolver$
10
+ DECLARE data_schema text := current_schema();
11
+ BEGIN
12
+ EXECUTE format($ddl$
13
+ CREATE OR REPLACE FUNCTION opengeni_private.resolve_document_index_authority(
14
+ p_account_id uuid,
15
+ p_workspace_id uuid,
16
+ p_document_id uuid
17
+ )
18
+ RETURNS TABLE (
19
+ authority_kind text,
20
+ authority_workspace_id uuid,
21
+ authority_subject_id text
22
+ )
23
+ LANGUAGE plpgsql
24
+ SECURITY DEFINER
25
+ SET search_path = pg_catalog
26
+ AS $body$
27
+ BEGIN
28
+ IF p_account_id IS DISTINCT FROM opengeni_private.current_account_id()
29
+ OR p_workspace_id IS DISTINCT FROM opengeni_private.current_workspace_id()
30
+ THEN
31
+ RAISE EXCEPTION 'document index authority lookup requires exact account/workspace RLS context'
32
+ USING ERRCODE = '42501';
33
+ END IF;
34
+
35
+ RETURN QUERY
36
+ SELECT
37
+ document.authority_kind,
38
+ document.authority_workspace_id,
39
+ document.authority_subject_id
40
+ FROM %1$I.documents document
41
+ WHERE document.account_id = p_account_id
42
+ AND document.workspace_id = p_workspace_id
43
+ AND document.id = p_document_id
44
+ LIMIT 1;
45
+ END;
46
+ $body$;
47
+ $ddl$, data_schema);
48
+ END
49
+ $document_index_authority_resolver$;
50
+
51
+ REVOKE ALL ON FUNCTION opengeni_private.resolve_document_index_authority(uuid, uuid, uuid)
52
+ FROM PUBLIC;
53
+
54
+ DO $runtime_grant$
55
+ BEGIN
56
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
57
+ GRANT EXECUTE ON FUNCTION opengeni_private.resolve_document_index_authority(uuid, uuid, uuid)
58
+ TO opengeni_app;
59
+ END IF;
60
+ END
61
+ $runtime_grant$;
@@ -0,0 +1,44 @@
1
+ -- deployment-mode: rolling
2
+
3
+ -- Immutable revision and activation rows are also the durable receipts for
4
+ -- policy-administration mutations. Legacy rows remain untouched and project
5
+ -- their immutable row id as a compatibility operation identity; new writers
6
+ -- persist stable UUIDs plus a secret-safe fingerprint of the complete canonical
7
+ -- request so only byte-equivalent semantics can replay the original result.
8
+ ALTER TABLE "workspace_instruction_policy_revisions"
9
+ ADD COLUMN IF NOT EXISTS "operation_id" uuid,
10
+ ADD COLUMN IF NOT EXISTS "request_fingerprint" text;
11
+
12
+ ALTER TABLE "workspace_instruction_policy_revisions"
13
+ ADD CONSTRAINT "workspace_instruction_policy_revisions_operation_receipt_chk"
14
+ CHECK (
15
+ ("operation_id" IS NULL AND "request_fingerprint" IS NULL)
16
+ OR (
17
+ "operation_id" IS NOT NULL
18
+ AND "request_fingerprint" ~ '^[0-9a-f]{64}$'
19
+ )
20
+ );
21
+
22
+ CREATE UNIQUE INDEX IF NOT EXISTS
23
+ "workspace_instruction_policy_revisions_workspace_operation_uq"
24
+ ON "workspace_instruction_policy_revisions" ("workspace_id", "operation_id")
25
+ WHERE "operation_id" IS NOT NULL;
26
+
27
+ ALTER TABLE "workspace_instruction_policy_activation_events"
28
+ ADD COLUMN IF NOT EXISTS "operation_id" uuid,
29
+ ADD COLUMN IF NOT EXISTS "request_fingerprint" text;
30
+
31
+ ALTER TABLE "workspace_instruction_policy_activation_events"
32
+ ADD CONSTRAINT "workspace_instruction_policy_events_operation_receipt_chk"
33
+ CHECK (
34
+ ("operation_id" IS NULL AND "request_fingerprint" IS NULL)
35
+ OR (
36
+ "operation_id" IS NOT NULL
37
+ AND "request_fingerprint" ~ '^[0-9a-f]{64}$'
38
+ )
39
+ );
40
+
41
+ CREATE UNIQUE INDEX IF NOT EXISTS
42
+ "workspace_instruction_policy_events_workspace_operation_uq"
43
+ ON "workspace_instruction_policy_activation_events" ("workspace_id", "operation_id")
44
+ WHERE "operation_id" IS NOT NULL;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/db",
3
- "version": "0.23.0",
3
+ "version": "0.26.0",
4
4
  "description": "OpenGeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -51,8 +51,8 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@opengeni/codex": "^0.2.10",
54
- "@opengeni/config": "^0.10.3",
55
- "@opengeni/contracts": "^0.32.0",
54
+ "@opengeni/config": "^0.10.6",
55
+ "@opengeni/contracts": "^0.35.0",
56
56
  "@opengeni/network": "^0.1.1",
57
57
  "drizzle-orm": "^0.45.2",
58
58
  "postgres": "^3.4.7"
@@ -34,7 +34,14 @@ import {
34
34
  } from "./index";
35
35
 
36
36
  export type ResolveConnectionCredentialResult =
37
- | { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
37
+ | {
38
+ status: "ok";
39
+ headers: Record<string, string>;
40
+ connectionId: string;
41
+ /** Exact durable version when the credential came from the local connection store. */
42
+ connectionVersion?: number;
43
+ expiresAt?: Date | null;
44
+ }
38
45
  | {
39
46
  status: "auth_needed";
40
47
  reason: McpCredentialAuthNeededReason;
@@ -337,6 +344,29 @@ export type ConnectionBrokerDeps = {
337
344
  now: () => Date;
338
345
  };
339
346
 
347
+ export type PermanentConnectionRefreshFailure = {
348
+ workspaceId: string;
349
+ connectionId: string;
350
+ connectionVersion: number;
351
+ subjectId: string | null;
352
+ providerDomain: string;
353
+ httpStatus: number;
354
+ oauthErrorCode: string | null;
355
+ };
356
+
357
+ export type ConnectionTokenResolverOptions = {
358
+ /** Provider-aware transport override used by local/integration adapters. */
359
+ refreshTransport?: RefreshTransportOptions;
360
+ /**
361
+ * Optional provider adapter for an atomic, metadata-aware permanent refresh
362
+ * transition. Returning true means the adapter owned the transition (even if
363
+ * its CAS lost to newer truth); false falls back to generic needs_reauth.
364
+ */
365
+ transitionPermanentRefreshFailure?: (
366
+ failure: PermanentConnectionRefreshFailure,
367
+ ) => Promise<boolean>;
368
+ };
369
+
340
370
  export type RefreshTransportOptions = {
341
371
  fetchImpl?: FetchLike;
342
372
  dnsLookup?: DnsLookup;
@@ -361,6 +391,7 @@ export function buildConnectionTokenResolver(
361
391
  db: Database,
362
392
  settings: Settings,
363
393
  deps: ConnectionBrokerDeps = defaultDeps,
394
+ options: ConnectionTokenResolverOptions = {},
364
395
  ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
365
396
  type CredentialLookupInput = Pick<
366
397
  ResolveConnectionCredentialInput,
@@ -439,6 +470,7 @@ export function buildConnectionTokenResolver(
439
470
  status: "ok",
440
471
  headers,
441
472
  connectionId: cred.id,
473
+ connectionVersion: cred.version,
442
474
  expiresAt: cred.expiresAt,
443
475
  };
444
476
  };
@@ -451,7 +483,7 @@ export function buildConnectionTokenResolver(
451
483
  if (!key) {
452
484
  throw new Error("OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured");
453
485
  }
454
- const refreshed = await deps.refresh(cred, ref, settings);
486
+ const refreshed = await deps.refresh(cred, ref, settings, options.refreshTransport);
455
487
  const refreshRecord: Parameters<typeof recordConnectionTokenRefresh>[1] = {
456
488
  id: cred.id,
457
489
  version: cred.version,
@@ -541,19 +573,31 @@ export function buildConnectionTokenResolver(
541
573
  // Only a rejected grant may poison the connection; transient failures
542
574
  // (network errors, AS 5xx) leave it active so the next resolve retries.
543
575
  if (isPermanentRefreshError(error)) {
544
- await deps
545
- .setStatus(
546
- db,
547
- input.workspaceId,
548
- "needs_reauth",
549
- error instanceof Error ? error.message : String(error),
550
- {
576
+ let handled = false;
577
+ if (options.transitionPermanentRefreshFailure) {
578
+ try {
579
+ handled = await options.transitionPermanentRefreshFailure({
580
+ workspaceId: cred.workspaceId,
581
+ connectionId: cred.id,
582
+ connectionVersion: cred.version,
583
+ subjectId: cred.subjectId,
584
+ providerDomain: cred.providerDomain,
585
+ httpStatus: error.httpStatus,
586
+ oauthErrorCode: error.oauthErrorCode,
587
+ });
588
+ } catch {
589
+ handled = false;
590
+ }
591
+ }
592
+ if (!handled) {
593
+ await deps
594
+ .setStatus(db, input.workspaceId, "needs_reauth", error.message, {
551
595
  id: cred.id,
552
596
  version: cred.version,
553
597
  subjectId: cred.subjectId,
554
- },
555
- )
556
- .catch(() => undefined);
598
+ })
599
+ .catch(() => undefined);
600
+ }
557
601
  }
558
602
  return authNeeded(ref, "refresh_failed", cred.id);
559
603
  }
@@ -629,18 +673,20 @@ function canonicalResource(value: string): string {
629
673
 
630
674
  export class ConnectionRefreshHttpError extends Error {
631
675
  readonly httpStatus: number;
676
+ readonly oauthErrorCode: string | null;
632
677
 
633
- constructor(httpStatus: number) {
678
+ constructor(httpStatus: number, oauthErrorCode: string | null = null) {
634
679
  super(`connection refresh failed with HTTP ${httpStatus}`);
635
680
  this.name = "ConnectionRefreshHttpError";
636
681
  this.httpStatus = httpStatus;
682
+ this.oauthErrorCode = oauthErrorCode;
637
683
  }
638
684
  }
639
685
 
640
686
  // The token endpoint rejecting the grant itself means re-auth is the only way
641
687
  // forward. 429 (throttling) and 408 are transient despite being 4xx; network
642
688
  // failures and AS 5xx are likewise retryable.
643
- function isPermanentRefreshError(error: unknown): boolean {
689
+ function isPermanentRefreshError(error: unknown): error is ConnectionRefreshHttpError {
644
690
  return (
645
691
  error instanceof ConnectionRefreshHttpError &&
646
692
  error.httpStatus >= 400 &&
@@ -817,8 +863,10 @@ export async function refreshOAuthConnectionCredential(
817
863
  throw new ConnectionRefreshHttpError(response.status);
818
864
  }
819
865
  if (!response.ok) {
820
- await cancelResponseBody(response);
821
- throw new ConnectionRefreshHttpError(response.status);
866
+ throw new ConnectionRefreshHttpError(
867
+ response.status,
868
+ await readOAuthRefreshErrorCode(response),
869
+ );
822
870
  }
823
871
  const payload = await readResponseJsonBounded<Record<string, unknown>>(
824
872
  response,
@@ -853,6 +901,21 @@ export async function refreshOAuthConnectionCredential(
853
901
  };
854
902
  }
855
903
 
904
+ async function readOAuthRefreshErrorCode(response: Response): Promise<string | null> {
905
+ try {
906
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
907
+ response,
908
+ OAUTH_MAX_RESPONSE_BYTES,
909
+ "OAuth refresh error response",
910
+ );
911
+ const code = stringValue(payload.error);
912
+ return code && /^[a-z0-9_.-]{1,64}$/i.test(code) ? code : null;
913
+ } catch {
914
+ await cancelResponseBody(response);
915
+ return null;
916
+ }
917
+ }
918
+
856
919
  function expiresAtFromTokenResponse(
857
920
  payload: Record<string, unknown>,
858
921
  fallback: Date | null,