@kubun/plugin-connector 0.10.0 → 0.11.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.
@@ -1,20 +1,52 @@
1
- import type { EntityBatch } from '@kubun/connector';
1
+ import type { EntityBatch, SyncFailureSample } from '@kubun/connector';
2
2
  import type { StoreProvider } from '@kubun/db';
3
- import type { ClustersRecord, DocumentFieldsMeta } from '@kubun/protocol';
3
+ import type { ApplyVerifiedMutationResult, MutateDocumentsParams } from '@kubun/engine';
4
+ import type { Logger } from '@kubun/logger';
5
+ import type { ClustersRecord, DocumentData, DocumentFieldsMeta } from '@kubun/protocol';
6
+ export declare const MAX_LOGGED_ERRORS = 10;
7
+ /**
8
+ * Engine-signed document write helper. Signing with the engine identity while
9
+ * applying as `owner` (the account viewer) routes imports through the mutation
10
+ * log with field HLCs and `document:saved` events. Used by the sync path to
11
+ * build a {@link SignedDocumentWriter}.
12
+ */
13
+ export type MutateDocuments = (params: MutateDocumentsParams) => Promise<Array<ApplyVerifiedMutationResult>>;
14
+ /**
15
+ * Applies one imported document as a signed write owned by `owner`. Both the
16
+ * background sync path (via {@link MutateDocuments}) and the interactive action
17
+ * path (via the request's signed-set seam) supply this, so every import lands
18
+ * through the mutation pipeline — there is no direct graph-store write.
19
+ */
20
+ export type SignedDocumentWriter = (params: {
21
+ owner: string;
22
+ modelID: string;
23
+ unique: Uint8Array;
24
+ data: DocumentData;
25
+ }) => Promise<void>;
26
+ export type ProcessBatchError = SyncFailureSample;
4
27
  export type ProcessBatchResult = {
5
28
  created: number;
6
29
  updated: number;
7
30
  deleted: number;
8
31
  failed: number;
9
32
  documentIDs: Array<string>;
33
+ errors: Array<ProcessBatchError>;
10
34
  };
11
35
  export type EntityProcessorParams = {
12
36
  stores: StoreProvider;
13
37
  modelID: string;
14
38
  ownerDID: string;
15
39
  connectorName: string;
40
+ logger: Logger;
16
41
  edgeFields?: DocumentFieldsMeta;
17
42
  clusters?: ClustersRecord;
43
+ /**
44
+ * Signs and applies each imported document as `ownerDID` through the mutation
45
+ * pipeline. The sync path backs this with the engine's own-DB write helper;
46
+ * the interactive action path backs it with the request's in-transaction
47
+ * signed-set seam.
48
+ */
49
+ writeDocument: SignedDocumentWriter;
18
50
  };
19
51
  export declare class EntityProcessor {
20
52
  #private;
@@ -1 +1,191 @@
1
- import{DocumentID as e,DocumentModelID as t}from"@kubun/id";import{getGraphStore as r}from"@kubun/store-graph";export class EntityProcessor{#e;#t=null;#r;#o;#s;#n;#c;constructor(e){this.#e=e.stores,this.#r=t.fromString(e.modelID),this.#o=e.ownerDID,this.#s=e.connectorName,this.#n=e.edgeFields??{},this.#c=e.clusters}#i(t,r){let o=new TextEncoder().encode(`${t}:${r}`);return e.create(this.#r,this.#o,o)}#l(e){let r,o=this.#c;if(null==o)return;for(let t of Object.values(o))if(null!=t.record[e])return e;try{r=t.fromString(e)}catch{return}if(!r.isLocal)return;let s=r.index;for(let e of Object.values(o))for(let[t,r]of Object.entries(e.record))if(r===s)return t}#u(r){if(null==this.#c||0===Object.keys(this.#n).length)return r;let o={...r};for(let[s,n]of Object.entries(this.#n)){if("document"!==n.type||null==n.model)continue;let c=r[s];if(null==c||"object"!=typeof c)continue;let i=c.sourceService,l=c.sourceID;if("string"!=typeof i||"string"!=typeof l)continue;let u=this.#l(n.model);if(null==u)continue;let d=new TextEncoder().encode(`${i}:${l}`),a=e.create(t.fromString(u),this.#o,d);o[s]=a.toString()}return o}async processBatch(e){this.#t??=r(this.#e);let t=await this.#t,o=0,s=0,n=0,c=0,i=[];for(let r of e.entities)try{let e=this.#i(r.sourceService,r.sourceID),n=await t.getDocument(e),c={...r,lastSyncedAt:new Date().toISOString()},l=this.#u(c);if(null==n){let s=new TextEncoder().encode(`${r.sourceService}:${r.sourceID}`);await t.createDocument({id:e,owner:this.#o,unique:s,data:l}),o++}else await t.saveDocument({id:e,existing:n,data:l}),s++;i.push(e.toString())}catch{c++}if(e.deleted)for(let r of e.deleted)try{let e=this.#i(r.sourceService,r.sourceID),o=await t.getDocument(e);null!=o&&(await t.saveDocument({id:e,existing:o,data:{sourceService:r.sourceService,sourceID:r.sourceID,lastSyncedAt:new Date().toISOString(),_deleted:!0}}),n++)}catch{c++}return{created:o,updated:s,deleted:n,failed:c,documentIDs:i}}}
1
+ import { DocumentID, DocumentModelID } from '@kubun/id';
2
+ import { getGraphStore } from '@kubun/store-graph';
3
+ export const MAX_LOGGED_ERRORS = 10;
4
+ export class EntityProcessor {
5
+ #stores;
6
+ #graphStorePromise = null;
7
+ #modelID;
8
+ #ownerDID;
9
+ #connectorName;
10
+ #logger;
11
+ #edgeFields;
12
+ #clusters;
13
+ #writeDocument;
14
+ constructor(params){
15
+ this.#stores = params.stores;
16
+ this.#modelID = DocumentModelID.fromString(params.modelID);
17
+ this.#ownerDID = params.ownerDID;
18
+ this.#connectorName = params.connectorName;
19
+ this.#logger = params.logger;
20
+ this.#edgeFields = params.edgeFields ?? {};
21
+ this.#clusters = params.clusters;
22
+ this.#writeDocument = params.writeDocument;
23
+ }
24
+ #computeDocumentID(sourceService, sourceID) {
25
+ const unique = new TextEncoder().encode(`${sourceService}:${sourceID}`);
26
+ return DocumentID.create(this.#modelID, this.#ownerDID, unique);
27
+ }
28
+ #resolveTargetModelID(localOrGlobalModel) {
29
+ const clusters = this.#clusters;
30
+ if (clusters == null) return undefined;
31
+ // If it's already a global ID (exists in any cluster's record), use directly
32
+ for (const cluster of Object.values(clusters)){
33
+ if (cluster.record[localOrGlobalModel] != null) {
34
+ return localOrGlobalModel;
35
+ }
36
+ }
37
+ // Otherwise treat it as a local ID: parse the index, find matching global ID
38
+ let localModelID;
39
+ try {
40
+ localModelID = DocumentModelID.fromString(localOrGlobalModel);
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ if (!localModelID.isLocal) return undefined;
45
+ const targetIndex = localModelID.index;
46
+ for (const cluster of Object.values(clusters)){
47
+ for (const [globalID, index] of Object.entries(cluster.record)){
48
+ if (index === targetIndex) {
49
+ return globalID;
50
+ }
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+ #resolveEdgeReferences(data) {
56
+ const cluster = this.#clusters;
57
+ if (cluster == null || Object.keys(this.#edgeFields).length === 0) {
58
+ return data;
59
+ }
60
+ const resolved = {
61
+ ...data
62
+ };
63
+ for (const [field, meta] of Object.entries(this.#edgeFields)){
64
+ if (meta.type !== 'document' || meta.model == null) continue;
65
+ const value = data[field];
66
+ if (value == null || typeof value !== 'object') continue;
67
+ const ref = value;
68
+ const sourceService = ref.sourceService;
69
+ const sourceID = ref.sourceID;
70
+ if (typeof sourceService !== 'string' || typeof sourceID !== 'string') continue;
71
+ const targetModelID = this.#resolveTargetModelID(meta.model);
72
+ if (targetModelID == null) continue;
73
+ const unique = new TextEncoder().encode(`${sourceService}:${sourceID}`);
74
+ const docID = DocumentID.create(DocumentModelID.fromString(targetModelID), this.#ownerDID, unique);
75
+ resolved[field] = docID.toString();
76
+ }
77
+ return resolved;
78
+ }
79
+ async processBatch(batch) {
80
+ this.#graphStorePromise ??= getGraphStore(this.#stores);
81
+ const graphStore = await this.#graphStorePromise;
82
+ let created = 0;
83
+ let updated = 0;
84
+ let deleted = 0;
85
+ let failed = 0;
86
+ const documentIDs = [];
87
+ const errors = [];
88
+ let suppressed = 0;
89
+ for (const entity of batch.entities){
90
+ try {
91
+ const docID = this.#computeDocumentID(entity.sourceService, entity.sourceID);
92
+ const existing = await graphStore.getDocument(docID);
93
+ const rawData = {
94
+ ...entity,
95
+ lastSyncedAt: new Date().toISOString()
96
+ };
97
+ const data = this.#resolveEdgeReferences(rawData);
98
+ const unique = new TextEncoder().encode(`${entity.sourceService}:${entity.sourceID}`);
99
+ await this.#writeDocument({
100
+ owner: this.#ownerDID,
101
+ modelID: this.#modelID.toString(),
102
+ unique,
103
+ data
104
+ });
105
+ if (existing == null) {
106
+ created++;
107
+ } else {
108
+ updated++;
109
+ }
110
+ documentIDs.push(docID.toString());
111
+ } catch (err) {
112
+ failed++;
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ if (errors.length < MAX_LOGGED_ERRORS) {
115
+ errors.push({
116
+ sourceService: entity.sourceService,
117
+ sourceID: entity.sourceID,
118
+ message
119
+ });
120
+ this.#logger.warn('connector sync entity failed', {
121
+ connectorName: this.#connectorName,
122
+ modelID: this.#modelID.toString(),
123
+ sourceService: entity.sourceService,
124
+ sourceID: entity.sourceID,
125
+ error: message
126
+ });
127
+ } else {
128
+ suppressed++;
129
+ }
130
+ }
131
+ }
132
+ if (batch.deleted) {
133
+ for (const del of batch.deleted){
134
+ try {
135
+ const docID = this.#computeDocumentID(del.sourceService, del.sourceID);
136
+ const existing = await graphStore.getDocument(docID);
137
+ if (existing != null) {
138
+ const tombstone = {
139
+ sourceService: del.sourceService,
140
+ sourceID: del.sourceID,
141
+ lastSyncedAt: new Date().toISOString(),
142
+ _deleted: true
143
+ };
144
+ const unique = new TextEncoder().encode(`${del.sourceService}:${del.sourceID}`);
145
+ await this.#writeDocument({
146
+ owner: this.#ownerDID,
147
+ modelID: this.#modelID.toString(),
148
+ unique,
149
+ data: tombstone
150
+ });
151
+ deleted++;
152
+ }
153
+ } catch (err) {
154
+ failed++;
155
+ const message = err instanceof Error ? err.message : String(err);
156
+ if (errors.length < MAX_LOGGED_ERRORS) {
157
+ errors.push({
158
+ sourceService: del.sourceService,
159
+ sourceID: del.sourceID,
160
+ message
161
+ });
162
+ this.#logger.warn('connector sync entity failed', {
163
+ connectorName: this.#connectorName,
164
+ modelID: this.#modelID.toString(),
165
+ sourceService: del.sourceService,
166
+ sourceID: del.sourceID,
167
+ error: message
168
+ });
169
+ } else {
170
+ suppressed++;
171
+ }
172
+ }
173
+ }
174
+ }
175
+ if (suppressed > 0) {
176
+ this.#logger.warn('connector sync errors suppressed', {
177
+ connectorName: this.#connectorName,
178
+ modelID: this.#modelID.toString(),
179
+ suppressed
180
+ });
181
+ }
182
+ return {
183
+ created,
184
+ updated,
185
+ deleted,
186
+ failed,
187
+ documentIDs,
188
+ errors
189
+ };
190
+ }
191
+ }
@@ -12,6 +12,8 @@ export declare class DBSyncStateStore implements SyncStateStore {
12
12
  });
13
13
  get(connectorName: string, ownerDID: string): Promise<SyncState | null>;
14
14
  set(connectorName: string, ownerDID: string, state: SyncState): Promise<void>;
15
+ claimSync(connectorName: string, ownerDID: string, leaseMs: number): Promise<boolean>;
16
+ renewSync(connectorName: string, ownerDID: string, leaseMs: number): Promise<void>;
15
17
  delete(connectorName: string, ownerDID: string): Promise<void>;
16
18
  listForConnector(connectorName: string): Promise<Array<SyncStateEntry>>;
17
19
  }
package/lib/sync/state.js CHANGED
@@ -1 +1,57 @@
1
- export class DBSyncStateStore{#t;constructor(t){this.#t=t.api}async get(t,n){let e=await this.#t.getSyncState(t,n);return null==e?null:{checkpoint:null!=e.checkpoint?"string"==typeof e.checkpoint?JSON.parse(e.checkpoint):e.checkpoint:null,lastSyncedAt:e.lastSyncedAt,entityCount:e.entityCount,status:e.status,error:e.error??void 0}}async set(t,n,e){await this.#t.setSyncState(t,n,{checkpoint:null!=e.checkpoint?JSON.stringify(e.checkpoint):null,lastSyncedAt:e.lastSyncedAt,entityCount:e.entityCount,status:e.status,error:e.error??null})}async delete(t,n){await this.#t.deleteSyncState(t,n)}async listForConnector(t){return(await this.#t.listSyncStates(t)).map(t=>({connectorName:t.connectorName,ownerDID:t.ownerDID,checkpoint:null!=t.checkpoint?"string"==typeof t.checkpoint?JSON.parse(t.checkpoint):t.checkpoint:null,lastSyncedAt:t.lastSyncedAt,entityCount:t.entityCount,status:t.status,error:t.error??void 0}))}}
1
+ /**
2
+ * DB-backed SyncStateStore that wraps ConnectorAPI methods.
3
+ * Maps between the SyncState type (with unknown checkpoint) and the
4
+ * SyncStateData type (with JSON-serialized string checkpoint).
5
+ */ export class DBSyncStateStore {
6
+ #api;
7
+ constructor(params){
8
+ this.#api = params.api;
9
+ }
10
+ async get(connectorName, ownerDID) {
11
+ const data = await this.#api.getSyncState(connectorName, ownerDID);
12
+ if (data == null) {
13
+ return null;
14
+ }
15
+ return {
16
+ // ParseJSONResultsPlugin may have already parsed the checkpoint value
17
+ checkpoint: data.checkpoint != null ? typeof data.checkpoint === 'string' ? JSON.parse(data.checkpoint) : data.checkpoint : null,
18
+ lastSyncedAt: data.lastSyncedAt,
19
+ entityCount: data.entityCount,
20
+ status: data.status,
21
+ error: data.error ?? undefined,
22
+ leaseExpiresAt: data.leaseExpiresAt ?? undefined
23
+ };
24
+ }
25
+ async set(connectorName, ownerDID, state) {
26
+ await this.#api.setSyncState(connectorName, ownerDID, {
27
+ checkpoint: state.checkpoint != null ? JSON.stringify(state.checkpoint) : null,
28
+ lastSyncedAt: state.lastSyncedAt,
29
+ entityCount: state.entityCount,
30
+ status: state.status,
31
+ error: state.error ?? null
32
+ });
33
+ }
34
+ async claimSync(connectorName, ownerDID, leaseMs) {
35
+ return this.#api.claimSync(connectorName, ownerDID, leaseMs);
36
+ }
37
+ async renewSync(connectorName, ownerDID, leaseMs) {
38
+ await this.#api.renewSync(connectorName, ownerDID, leaseMs);
39
+ }
40
+ async delete(connectorName, ownerDID) {
41
+ await this.#api.deleteSyncState(connectorName, ownerDID);
42
+ }
43
+ async listForConnector(connectorName) {
44
+ const rows = await this.#api.listSyncStates(connectorName);
45
+ return rows.map((data)=>({
46
+ connectorName: data.connectorName,
47
+ ownerDID: data.ownerDID,
48
+ // ParseJSONResultsPlugin may have already parsed the checkpoint value
49
+ checkpoint: data.checkpoint != null ? typeof data.checkpoint === 'string' ? JSON.parse(data.checkpoint) : data.checkpoint : null,
50
+ lastSyncedAt: data.lastSyncedAt,
51
+ entityCount: data.entityCount,
52
+ status: data.status,
53
+ error: data.error ?? undefined,
54
+ leaseExpiresAt: data.leaseExpiresAt ?? undefined
55
+ }));
56
+ }
57
+ }
@@ -0,0 +1,35 @@
1
+ import type { CredentialProvider } from '@kubun/connector';
2
+ import { HLC } from '@kubun/hlc';
3
+ import { getDelegationStore } from '@kubun/store-delegation';
4
+ import type { ConnectorRegistry } from './registry.js';
5
+ import type { ConnectorWriteGrant } from './schema.js';
6
+ export type WriteGrantContextParams = {
7
+ registry: ConnectorRegistry;
8
+ /** The server identity a viewer delegates its write authority to (`aud`). */
9
+ serverDID: string;
10
+ /** The calling viewer (raw, unnormalized) — the grantor of every cap. */
11
+ viewerDID: string;
12
+ stores: Parameters<typeof getDelegationStore>[0];
13
+ credentialProvider: CredentialProvider;
14
+ hlc: HLC;
15
+ };
16
+ export type WriteGrantContext = {
17
+ grantWriteCapability(args: {
18
+ connector: string;
19
+ tokens: Array<string>;
20
+ }): Promise<boolean>;
21
+ revokeConnectorWriteCapability(args: {
22
+ connector: string;
23
+ }): Promise<boolean>;
24
+ disconnectProvider(args: {
25
+ provider: string;
26
+ }): Promise<boolean>;
27
+ connectorWriteGrants(): Promise<Array<ConnectorWriteGrant>>;
28
+ };
29
+ /**
30
+ * Build the viewer→server write-capability context methods (grant, per-connector
31
+ * revoke, per-provider disconnect, and the live-grant listing). Authorization of
32
+ * the writes these caps enable happens in the engine off the signed token; the
33
+ * stored `resource` string handled here is only the revoke/list selector.
34
+ */
35
+ export declare function createWriteGrantContext(params: WriteGrantContextParams): WriteGrantContext;
@@ -0,0 +1,286 @@
1
+ import { normalizeDID, verifyToken } from '@kokuin/token';
2
+ import { clusterModelURNs } from '@kubun/connector';
3
+ import { HLC } from '@kubun/hlc';
4
+ import { getDelegationStore } from '@kubun/store-delegation';
5
+ import { GraphQLError } from 'graphql';
6
+ /**
7
+ * Upper bound on the lifetime of an accepted write-capability grant (1 year).
8
+ * A viewer's grant delegates its own write authority to the server; a grant
9
+ * whose expiry is further out than this is rejected as implausibly long-lived.
10
+ */ const MAX_GRANT_LIFETIME_SECONDS = 365 * 24 * 60 * 60;
11
+ // The stored `res` claim comes back either as an already-parsed array of model
12
+ // URNs (Kysely's ParseJSONResultsPlugin decodes JSON-array text on read) or as
13
+ // a raw scalar string for a legacy `'*'`-style capability. Normalize both to a
14
+ // set of resource strings so callers can compare res-sets uniformly.
15
+ function parseResourceSet(raw) {
16
+ if (Array.isArray(raw) && raw.every((item)=>typeof item === 'string')) {
17
+ return new Set(raw);
18
+ }
19
+ if (typeof raw === 'string') {
20
+ try {
21
+ const value = JSON.parse(raw);
22
+ return Array.isArray(value) && value.every((item)=>typeof item === 'string') ? new Set(value) : new Set([
23
+ raw
24
+ ]);
25
+ } catch (_error) {
26
+ return new Set([
27
+ raw
28
+ ]);
29
+ }
30
+ }
31
+ return new Set([
32
+ String(raw)
33
+ ]);
34
+ }
35
+ function setsEqual(a, b) {
36
+ return a.size === b.size && [
37
+ ...a
38
+ ].every((item)=>b.has(item));
39
+ }
40
+ // Hard-delete every held token whose res-set exactly equals ANY of
41
+ // `targetSets`, leaving broader/`*`/unrelated caps intact. Reads the held
42
+ // tokens once; returns the number of rows removed. Shared by the
43
+ // per-connector revoke and the per-provider disconnect flows.
44
+ async function revokeCapsMatchingSets(delegationStore, grantor, audience, targetSets) {
45
+ const held = await delegationStore.getDelegationTokens({
46
+ grantor,
47
+ audience
48
+ });
49
+ let removed = 0;
50
+ for (const row of held){
51
+ const parsed = parseResourceSet(row.resource);
52
+ if (targetSets.some((target)=>setsEqual(parsed, target))) {
53
+ if (await delegationStore.removeDelegationToken({
54
+ jti: row.jti
55
+ })) {
56
+ removed += 1;
57
+ }
58
+ }
59
+ }
60
+ return removed;
61
+ }
62
+ // Hard-delete every held token whose non-empty res-set is a SUBSET of
63
+ // `allowed`. Used by provider disconnect so a cap scoped to any subset of the
64
+ // provider's connector models (including after a model-set change) is dropped,
65
+ // while caps covering models outside the provider stay intact.
66
+ async function revokeCapsSubsetOf(delegationStore, grantor, audience, allowed) {
67
+ const held = await delegationStore.getDelegationTokens({
68
+ grantor,
69
+ audience
70
+ });
71
+ let removed = 0;
72
+ for (const row of held){
73
+ const parsed = parseResourceSet(row.resource);
74
+ if (parsed.size > 0 && [
75
+ ...parsed
76
+ ].every((urn)=>allowed.has(urn))) {
77
+ if (await delegationStore.removeDelegationToken({
78
+ jti: row.jti
79
+ })) removed += 1;
80
+ }
81
+ }
82
+ return removed;
83
+ }
84
+ /**
85
+ * Build the viewer→server write-capability context methods (grant, per-connector
86
+ * revoke, per-provider disconnect, and the live-grant listing). Authorization of
87
+ * the writes these caps enable happens in the engine off the signed token; the
88
+ * stored `resource` string handled here is only the revoke/list selector.
89
+ */ export function createWriteGrantContext(params) {
90
+ const { registry, serverDID, credentialProvider, stores, hlc } = params;
91
+ return {
92
+ grantWriteCapability: async ({ connector, tokens })=>{
93
+ const connectorDef = registry.get(connector);
94
+ if (connectorDef == null) {
95
+ throw new GraphQLError(`Connector "${connector}" not found`, {
96
+ extensions: {
97
+ code: 'CONNECTOR_NOT_FOUND'
98
+ }
99
+ });
100
+ }
101
+ // A grant may scope its write authority to no broader than this
102
+ // connector's own models. Bounding `res` server-side means a
103
+ // viewer's least-privilege intent is enforced here, not only
104
+ // trusted from the client that minted the token.
105
+ const allowedURNs = new Set(clusterModelURNs(connectorDef.clusters));
106
+ const viewerDID = normalizeDID(params.viewerDID);
107
+ const audienceDID = normalizeDID(serverDID);
108
+ const now = Math.floor(Date.now() / 1000);
109
+ // Validate every token first — a single invalid token rejects the
110
+ // whole batch, so nothing is stored unless all tokens pass. The
111
+ // per-token writes below run inside the mutation transaction, so a
112
+ // later failure would also roll the batch back.
113
+ const accepted = [];
114
+ for (const token of tokens){
115
+ let payload;
116
+ try {
117
+ const verified = await verifyToken(token);
118
+ payload = verified.payload;
119
+ } catch (_error) {
120
+ throw new GraphQLError('write-capability grant failed signature verification', {
121
+ extensions: {
122
+ code: 'CONNECTOR_WRITE_GRANT_INVALID'
123
+ }
124
+ });
125
+ }
126
+ // The grant must be self-signed by the viewer delegating its own
127
+ // authority: issuer and subject are both the calling viewer.
128
+ if (normalizeDID(payload.iss) !== viewerDID || normalizeDID(payload.sub) !== viewerDID) {
129
+ throw new GraphQLError('write-capability grant must be self-signed by the viewer', {
130
+ extensions: {
131
+ code: 'CONNECTOR_WRITE_GRANT_INVALID'
132
+ }
133
+ });
134
+ }
135
+ // The grant must delegate to this server so the engine's
136
+ // auto-attach picks it up on later server-signed writes.
137
+ if (normalizeDID(payload.aud) !== audienceDID) {
138
+ throw new GraphQLError('write-capability grant is not addressed to this server', {
139
+ extensions: {
140
+ code: 'CONNECTOR_WRITE_GRANT_INVALID'
141
+ }
142
+ });
143
+ }
144
+ if (payload.act !== 'document/write') {
145
+ throw new GraphQLError('write-capability grant does not grant document/write', {
146
+ extensions: {
147
+ code: 'CONNECTOR_WRITE_GRANT_INVALID'
148
+ }
149
+ });
150
+ }
151
+ if (typeof payload.exp !== 'number' || payload.exp <= now || payload.exp > now + MAX_GRANT_LIFETIME_SECONDS) {
152
+ throw new GraphQLError('write-capability grant has an invalid expiry', {
153
+ extensions: {
154
+ code: 'CONNECTOR_WRITE_GRANT_INVALID'
155
+ }
156
+ });
157
+ }
158
+ if (payload.jti == null) {
159
+ throw new GraphQLError('write-capability grant is missing a token id', {
160
+ extensions: {
161
+ code: 'CONNECTOR_WRITE_GRANT_INVALID'
162
+ }
163
+ });
164
+ }
165
+ // The grant's resource set must be a non-empty subset of this
166
+ // connector's models — an empty scope, a `'*'` wildcard, or any
167
+ // out-of-scope URN exceeds what the viewer may delegate here.
168
+ const resSet = parseResourceSet(payload.res);
169
+ if (!(resSet.size > 0 && [
170
+ ...resSet
171
+ ].every((urn)=>allowedURNs.has(urn)))) {
172
+ throw new GraphQLError('write-capability grant resource exceeds connector scope', {
173
+ extensions: {
174
+ code: 'CONNECTOR_WRITE_GRANT_INVALID'
175
+ }
176
+ });
177
+ }
178
+ const resource = typeof payload.res === 'string' ? payload.res : JSON.stringify(payload.res);
179
+ accepted.push({
180
+ jti: payload.jti,
181
+ token,
182
+ resource,
183
+ exp: payload.exp
184
+ });
185
+ }
186
+ const delegationStore = await getDelegationStore(stores);
187
+ const hlcNow = HLC.serialize(hlc.now());
188
+ for (const entry of accepted){
189
+ await delegationStore.addDelegationToken({
190
+ jti: entry.jti,
191
+ grantor: viewerDID,
192
+ audience: audienceDID,
193
+ token: entry.token,
194
+ resource: entry.resource,
195
+ act: 'document/write',
196
+ exp: entry.exp,
197
+ hlc: hlcNow
198
+ });
199
+ }
200
+ return true;
201
+ },
202
+ revokeConnectorWriteCapability: async ({ connector })=>{
203
+ const connectorDef = registry.get(connector);
204
+ if (connectorDef == null) {
205
+ throw new GraphQLError(`Connector "${connector}" not found`, {
206
+ extensions: {
207
+ code: 'CONNECTOR_NOT_FOUND'
208
+ }
209
+ });
210
+ }
211
+ // Only a held cap scoped to precisely this connector's model set
212
+ // is dropped — broader caps (`*`) and other-connector caps stay
213
+ // intact.
214
+ const target = new Set(clusterModelURNs(connectorDef.clusters));
215
+ const viewerDID = normalizeDID(params.viewerDID);
216
+ const audienceDID = normalizeDID(serverDID);
217
+ const delegationStore = await getDelegationStore(stores);
218
+ const removed = await revokeCapsMatchingSets(delegationStore, viewerDID, audienceDID, [
219
+ target
220
+ ]);
221
+ return removed > 0;
222
+ },
223
+ disconnectProvider: async ({ provider })=>{
224
+ // Disconnecting a provider revokes every connector cap the viewer
225
+ // delegated for connectors backed by it, then drops the shared
226
+ // OAuth credential. A provider with zero registered connectors
227
+ // still runs the credential cleanup — no unknown-provider throw.
228
+ const viewerDID = normalizeDID(params.viewerDID);
229
+ const audienceDID = normalizeDID(serverDID);
230
+ const connectors = registry.getAll().filter((connector)=>connector.auth.provider === provider);
231
+ // Union of every model backed by this provider. Any held cap whose
232
+ // scope falls within that union is dropped — this covers a cap that
233
+ // no longer matches a connector's current model set exactly (drift),
234
+ // which an exact-set revoke would miss. Zero connectors → empty
235
+ // union → nothing revoked, leaving only the credential cleanup.
236
+ const providerURNs = new Set();
237
+ for (const connector of connectors){
238
+ for (const urn of clusterModelURNs(connector.clusters))providerURNs.add(urn);
239
+ }
240
+ const delegationStore = await getDelegationStore(stores);
241
+ const removed = await revokeCapsSubsetOf(delegationStore, viewerDID, audienceDID, providerURNs);
242
+ const credentialExisted = await credentialProvider.get(provider, viewerDID) != null;
243
+ await credentialProvider.delete(provider, viewerDID);
244
+ return removed > 0 || credentialExisted;
245
+ },
246
+ connectorWriteGrants: async ()=>{
247
+ const viewerDID = normalizeDID(params.viewerDID);
248
+ const audienceDID = normalizeDID(serverDID);
249
+ const now = Math.floor(Date.now() / 1000);
250
+ const delegationStore = await getDelegationStore(stores);
251
+ const held = await delegationStore.getDelegationTokens({
252
+ grantor: viewerDID,
253
+ audience: audienceDID
254
+ });
255
+ const revoked = new Set((await delegationStore.getHeldRevocations({
256
+ audience: audienceDID
257
+ })).map((row)=>row.jti));
258
+ const connectors = registry.getAll().map((connector)=>({
259
+ name: connector.name,
260
+ provider: connector.auth.provider,
261
+ set: new Set(clusterModelURNs(connector.clusters))
262
+ }));
263
+ const grants = [];
264
+ for (const row of held){
265
+ // Skip expired and already-revoked caps; only surface live,
266
+ // per-connector grants. A `*` or otherwise broader cap matches
267
+ // no connector set and is intentionally not listed.
268
+ if (row.exp <= now) continue;
269
+ if (revoked.has(row.jti)) continue;
270
+ const parsed = parseResourceSet(row.resource);
271
+ const match = connectors.find((connector)=>setsEqual(parsed, connector.set));
272
+ if (match == null) continue;
273
+ grants.push({
274
+ connector: match.name,
275
+ provider: match.provider,
276
+ modelURNs: [
277
+ ...match.set
278
+ ],
279
+ exp: row.exp,
280
+ jti: row.jti
281
+ });
282
+ }
283
+ return grants;
284
+ }
285
+ };
286
+ }