@kubun/plugin-connector 0.9.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.
- package/lib/action.d.ts +10 -0
- package/lib/action.js +106 -1
- package/lib/api.d.ts +9 -0
- package/lib/api.js +108 -1
- package/lib/boundary.js +73 -1
- package/lib/credential.d.ts +1 -1
- package/lib/credential.js +107 -1
- package/lib/index.d.ts +3 -1
- package/lib/index.js +223 -1
- package/lib/manager.d.ts +1 -1
- package/lib/manager.js +90 -1
- package/lib/oauth.d.ts +5 -3
- package/lib/oauth.js +137 -1
- package/lib/registry.js +24 -1
- package/lib/schema.d.ts +44 -2
- package/lib/schema.js +300 -13
- package/lib/sync/engine.d.ts +6 -0
- package/lib/sync/engine.js +163 -1
- package/lib/sync/orchestrate.d.ts +6 -0
- package/lib/sync/orchestrate.js +100 -1
- package/lib/sync/processor.d.ts +34 -2
- package/lib/sync/processor.js +191 -1
- package/lib/sync/state.d.ts +2 -0
- package/lib/sync/state.js +57 -1
- package/lib/write-grants.d.ts +35 -0
- package/lib/write-grants.js +286 -0
- package/package.json +26 -20
package/lib/sync/processor.d.ts
CHANGED
|
@@ -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 {
|
|
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;
|
package/lib/sync/processor.js
CHANGED
|
@@ -1 +1,191 @@
|
|
|
1
|
-
import{DocumentID
|
|
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
|
+
}
|
package/lib/sync/state.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|