@kubun/plugin-connector 0.13.1 → 0.14.1
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 +2 -1
- package/lib/api.d.ts +2 -54
- package/lib/api.js +1 -116
- package/lib/credential.d.ts +2 -22
- package/lib/credential.js +4 -129
- package/lib/index.d.ts +6 -3
- package/lib/index.js +244 -52
- package/lib/oauth.d.ts +5 -3
- package/lib/oauth.js +7 -2
- package/lib/schema.d.ts +5 -0
- package/lib/schema.js +53 -0
- package/lib/sync/orchestrate.d.ts +2 -1
- package/lib/sync/orchestrate.js +112 -76
- package/lib/sync/workflow.d.ts +4 -1
- package/lib/sync/workflow.js +29 -6
- package/lib/write-grants.d.ts +1 -1
- package/package.json +37 -27
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createCredentialManager } from '@kubun/credential';
|
|
|
3
3
|
import { connectorStoreDefinition } from '@kubun/store-connector';
|
|
4
4
|
import { credentialStoreDefinition, getCredentialStore } from '@kubun/store-credential';
|
|
5
5
|
import { executeAction } from './action.js';
|
|
6
|
-
import { createConnectorAPI } from './api.js';
|
|
6
|
+
import { createConnectorAPI, createCredentialAPI } from './api.js';
|
|
7
7
|
import { DBCredentialProvider } from './credential.js';
|
|
8
8
|
import { ConnectorManager } from './manager.js';
|
|
9
9
|
import { OAuthService } from './oauth.js';
|
|
@@ -26,13 +26,60 @@ export { EntityProcessor } from './sync/processor.js';
|
|
|
26
26
|
export { DBSyncStateStore } from './sync/state.js';
|
|
27
27
|
export { CONNECTOR_SYNC_CONCURRENCY, CONNECTOR_SYNC_WORKFLOW, createConnectorSyncWorkflow } from './sync/workflow.js';
|
|
28
28
|
export { DBCredentialProvider };
|
|
29
|
+
// 5m after a productive run; idle 15m→6h, error 1m→1h, offline 30s→15m.
|
|
30
|
+
const DEFAULT_PERIODIC_SYNC_POLICY = {
|
|
31
|
+
changed: 300000,
|
|
32
|
+
idle: {
|
|
33
|
+
base: 900000,
|
|
34
|
+
max: 21600000
|
|
35
|
+
},
|
|
36
|
+
error: {
|
|
37
|
+
base: 60000,
|
|
38
|
+
max: 3600000
|
|
39
|
+
},
|
|
40
|
+
offline: {
|
|
41
|
+
base: 30000,
|
|
42
|
+
max: 900000
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
// Present the standalone local stack as the unified surface: the credential
|
|
46
|
+
// provider for get/set/delete, its credential API for provenance. Service mode
|
|
47
|
+
// gets provenance as one more procedure, so both modes read it off one object.
|
|
48
|
+
function withProvenance(provider, api) {
|
|
49
|
+
return {
|
|
50
|
+
get: (providerName, ownerDID)=>provider.get(providerName, ownerDID),
|
|
51
|
+
set: (setParams)=>provider.set(setParams),
|
|
52
|
+
delete: (providerName, ownerDID)=>provider.delete(providerName, ownerDID),
|
|
53
|
+
getCredentialProvenance: (providerName, ownerDID)=>api.getCredentialProvenance(providerName, ownerDID)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
29
56
|
// ---- Connector state resolution ----
|
|
30
|
-
|
|
57
|
+
// Map a workflow instance status to the connector display status. A run in
|
|
58
|
+
// progress reads SYNCING, a failed terminal ERROR; every other terminal
|
|
59
|
+
// (completed/cancelled) is IDLE.
|
|
60
|
+
function displayStatusFor(status) {
|
|
61
|
+
if (status === 'pending' || status === 'running') return 'SYNCING';
|
|
62
|
+
if (status === 'failed') return 'ERROR';
|
|
63
|
+
return 'IDLE';
|
|
64
|
+
}
|
|
65
|
+
async function resolveConnectorState({ connectorAPI, registry, credentialProvider, getWorkflowAPI, viewerDID, connectorName }) {
|
|
31
66
|
const connector = registry.get(connectorName);
|
|
32
67
|
if (connector == null) {
|
|
33
68
|
throw new Error(`Connector "${connectorName}" not found`);
|
|
34
69
|
}
|
|
35
70
|
const syncState = await connectorAPI.getSyncState(connectorName, viewerDID);
|
|
71
|
+
// The durable, lease-fenced truth: the workflow instance status. Preferred over
|
|
72
|
+
// the hand-written syncState.status row, which a zombie handler can transiently
|
|
73
|
+
// regress after crash-recovery. Falls back to that row only with no workflow
|
|
74
|
+
// plugin (local-only sync) or no instance yet.
|
|
75
|
+
let workflowStatus = null;
|
|
76
|
+
const workflowAPI = await getWorkflowAPI();
|
|
77
|
+
if (workflowAPI != null) {
|
|
78
|
+
const instance = await workflowAPI.getCurrentInstance(CONNECTOR_SYNC_WORKFLOW, `${connectorName}:${viewerDID}`);
|
|
79
|
+
if (instance != null) {
|
|
80
|
+
workflowStatus = displayStatusFor(instance.status);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
36
83
|
let authenticated = false;
|
|
37
84
|
let hasWriteAccess = false;
|
|
38
85
|
let authExpiresAt = null;
|
|
@@ -42,7 +89,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
|
|
|
42
89
|
const credential = await credentialProvider.get(connector.auth.provider, viewerDID);
|
|
43
90
|
if (credential != null) {
|
|
44
91
|
authenticated = true;
|
|
45
|
-
const provenance = await
|
|
92
|
+
const provenance = await credentialProvider.getCredentialProvenance(connector.auth.provider, viewerDID);
|
|
46
93
|
credentialUpdatedAt = provenance?.updatedAt ?? null;
|
|
47
94
|
credentialWriterDID = provenance?.writerDID ?? null;
|
|
48
95
|
if (credential.expiresAt != null) {
|
|
@@ -59,7 +106,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
|
|
|
59
106
|
if (syncState == null) {
|
|
60
107
|
return {
|
|
61
108
|
name: connectorName,
|
|
62
|
-
status: 'IDLE',
|
|
109
|
+
status: workflowStatus ?? 'IDLE',
|
|
63
110
|
authenticated,
|
|
64
111
|
hasWriteAccess,
|
|
65
112
|
authExpiresAt,
|
|
@@ -72,7 +119,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
|
|
|
72
119
|
}
|
|
73
120
|
return {
|
|
74
121
|
name: connectorName,
|
|
75
|
-
status: syncState.status === 'idle' ? 'IDLE' : syncState.status === 'syncing' ? 'SYNCING' : 'ERROR',
|
|
122
|
+
status: workflowStatus ?? (syncState.status === 'idle' ? 'IDLE' : syncState.status === 'syncing' ? 'SYNCING' : 'ERROR'),
|
|
76
123
|
authenticated,
|
|
77
124
|
hasWriteAccess,
|
|
78
125
|
authExpiresAt,
|
|
@@ -123,39 +170,98 @@ export function createConnectorPlugin(options) {
|
|
|
123
170
|
// One manager per StoreProvider: the request-scoped provider is the
|
|
124
171
|
// mutation's transaction, and a manager built over the base provider would
|
|
125
172
|
// read outside it. The key cache is per-manager, which is the same scope the
|
|
126
|
-
// content key should have.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
173
|
+
// content key should have. Parameterised by the signing identity and HLC so a
|
|
174
|
+
// manager over an external credential graph stamps with that graph's writer,
|
|
175
|
+
// not the engine's.
|
|
176
|
+
const credentialManagerFor = (mgrIdentity, mgrHLC)=>{
|
|
177
|
+
const managers = new WeakMap();
|
|
178
|
+
return (stores)=>{
|
|
179
|
+
let manager = managers.get(stores);
|
|
180
|
+
if (manager == null) {
|
|
181
|
+
manager = getCredentialStore(stores).then((store)=>createCredentialManager({
|
|
182
|
+
store,
|
|
183
|
+
identity: mgrIdentity,
|
|
184
|
+
runtime: params.runtime,
|
|
185
|
+
hlc: mgrHLC
|
|
186
|
+
}));
|
|
187
|
+
managers.set(stores, manager);
|
|
188
|
+
}
|
|
189
|
+
return manager;
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
// The engine-scoped builder: still the manager for the engine's own request
|
|
193
|
+
// transaction on the no-backend path.
|
|
194
|
+
const getCredentialManager = credentialManagerFor(identity, params.hlc);
|
|
141
195
|
// Controller resolvers for `did:kokuin:` wrapping recipients, scoped to the
|
|
142
|
-
//
|
|
143
|
-
// than a second connection.
|
|
196
|
+
// resolved provider so a resolve reads that provider's controller store
|
|
197
|
+
// rather than a second connection.
|
|
144
198
|
const getControllerMethods = (stores)=>[
|
|
145
199
|
params.controllerResolverFor(stores)
|
|
146
200
|
];
|
|
201
|
+
// Mode is decided by provider presence, resolved once. A registered
|
|
202
|
+
// `credential-provider` (service mode) routes every credential op through the
|
|
203
|
+
// remote credential service; its absence (standalone) builds the local stack
|
|
204
|
+
// over the engine's own db, exactly as before. The registry gate closes after
|
|
205
|
+
// construction, so this promise settles lazily — memoized by capture.
|
|
206
|
+
const credentialServiceProviderPromise = params.engine.getProvider('credential-provider');
|
|
207
|
+
// The standalone credential stack over the engine's own db, built lazily and
|
|
208
|
+
// once — only ever touched when no service provider is registered. `stores`
|
|
209
|
+
// defaults to params.db for the background (workflow/orchestrate) paths, which
|
|
210
|
+
// run outside any request transaction. A request path passes its own tx.
|
|
211
|
+
let standaloneStack;
|
|
212
|
+
const getStandaloneStack = (stores = params.db)=>{
|
|
213
|
+
// The background stack is cached on params.db; a per-request tx builds a
|
|
214
|
+
// fresh stack so its reads and writes join that transaction.
|
|
215
|
+
if (stores === params.db && standaloneStack != null) return standaloneStack;
|
|
216
|
+
const api = createCredentialAPI({
|
|
217
|
+
stores,
|
|
218
|
+
logger,
|
|
219
|
+
getCredentialManager,
|
|
220
|
+
serverWrappableDID,
|
|
221
|
+
getControllerMethods
|
|
222
|
+
});
|
|
223
|
+
const stack = {
|
|
224
|
+
api,
|
|
225
|
+
provider: new DBCredentialProvider({
|
|
226
|
+
api,
|
|
227
|
+
runtime: params.runtime,
|
|
228
|
+
providers
|
|
229
|
+
})
|
|
230
|
+
};
|
|
231
|
+
if (stores === params.db) standaloneStack = stack;
|
|
232
|
+
return stack;
|
|
233
|
+
};
|
|
234
|
+
// Resolve the unified credential surface for a given store provider. Service
|
|
235
|
+
// mode reuses the registry provider as-is (plugin-scoped, not the request tx);
|
|
236
|
+
// standalone wraps the local stack, exposing provenance off its credential API.
|
|
237
|
+
const resolveCredentialSurface = async (stores)=>{
|
|
238
|
+
const serviceProvider = await credentialServiceProviderPromise;
|
|
239
|
+
if (serviceProvider != null) {
|
|
240
|
+
return {
|
|
241
|
+
provider: serviceProvider
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const { api, provider } = getStandaloneStack(stores);
|
|
245
|
+
return {
|
|
246
|
+
provider: withProvenance(provider, api)
|
|
247
|
+
};
|
|
248
|
+
};
|
|
147
249
|
const connectorAPI = createConnectorAPI({
|
|
148
250
|
stores: params.db,
|
|
149
|
-
logger
|
|
150
|
-
getCredentialManager,
|
|
151
|
-
serverWrappableDID,
|
|
152
|
-
getControllerMethods
|
|
153
|
-
});
|
|
154
|
-
const credentialProvider = new DBCredentialProvider({
|
|
155
|
-
api: connectorAPI,
|
|
156
|
-
runtime: params.runtime,
|
|
157
|
-
providers
|
|
251
|
+
logger
|
|
158
252
|
});
|
|
253
|
+
// The plugin-scope credential surface for the background paths (workflow,
|
|
254
|
+
// fallback orchestrate, OAuth callback fallback). It switches on mode per call
|
|
255
|
+
// so a service provider registered after construction is still honoured.
|
|
256
|
+
const backgroundCredentialProvider = {
|
|
257
|
+
get: async (providerName, ownerDID)=>(await resolveCredentialSurface(params.db)).provider.get(providerName, ownerDID),
|
|
258
|
+
set: async (setParams)=>{
|
|
259
|
+
await (await resolveCredentialSurface(params.db)).provider.set(setParams);
|
|
260
|
+
},
|
|
261
|
+
delete: async (providerName, ownerDID)=>{
|
|
262
|
+
await (await resolveCredentialSurface(params.db)).provider.delete(providerName, ownerDID);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
159
265
|
const stateStore = new DBSyncStateStore({
|
|
160
266
|
api: connectorAPI
|
|
161
267
|
});
|
|
@@ -168,7 +274,7 @@ export function createConnectorPlugin(options) {
|
|
|
168
274
|
runtime: params.runtime,
|
|
169
275
|
providers,
|
|
170
276
|
registry,
|
|
171
|
-
credentialProvider,
|
|
277
|
+
credentialProvider: backgroundCredentialProvider,
|
|
172
278
|
connectorAPI
|
|
173
279
|
});
|
|
174
280
|
// The primary sync path: enqueue a durable `connector-sync` workflow whose
|
|
@@ -176,7 +282,7 @@ export function createConnectorPlugin(options) {
|
|
|
176
282
|
// registered lazily the first time the workflow API resolves.
|
|
177
283
|
const connectorSyncWorkflow = createConnectorSyncWorkflow({
|
|
178
284
|
registry,
|
|
179
|
-
credentialProvider,
|
|
285
|
+
credentialProvider: backgroundCredentialProvider,
|
|
180
286
|
stateStore,
|
|
181
287
|
syncEventEmitter: manager,
|
|
182
288
|
stores: params.db,
|
|
@@ -209,28 +315,60 @@ export function createConnectorPlugin(options) {
|
|
|
209
315
|
api: connectorAPI,
|
|
210
316
|
createContextFactory: ()=>{
|
|
211
317
|
return (ctx, stores)=>{
|
|
212
|
-
// Per-request connector API
|
|
213
|
-
//
|
|
318
|
+
// Per-request connector API backed by the request's StoreProvider
|
|
319
|
+
// (transactional during mutations). Sync-state stays Space-local, so
|
|
320
|
+
// this always rebuilds over the request tx.
|
|
214
321
|
const requestAPI = createConnectorAPI({
|
|
215
322
|
stores,
|
|
216
|
-
logger
|
|
217
|
-
getCredentialManager,
|
|
218
|
-
serverWrappableDID,
|
|
219
|
-
getControllerMethods
|
|
220
|
-
});
|
|
221
|
-
const requestCredentialProvider = new DBCredentialProvider({
|
|
222
|
-
api: requestAPI,
|
|
223
|
-
runtime: params.runtime,
|
|
224
|
-
providers
|
|
323
|
+
logger
|
|
225
324
|
});
|
|
325
|
+
// Resolve the request's credential surface once. Service mode reuses the
|
|
326
|
+
// registry provider (not the request tx); standalone rebuilds the local
|
|
327
|
+
// stack over the request's transactional provider.
|
|
328
|
+
let surfacePromise;
|
|
329
|
+
const getSurface = ()=>{
|
|
330
|
+
if (surfacePromise == null) surfacePromise = resolveCredentialSurface(stores);
|
|
331
|
+
return surfacePromise;
|
|
332
|
+
};
|
|
333
|
+
// A base provider that defers to the resolved surface, so synchronous
|
|
334
|
+
// consumers (write-grants, executeAction) get one object while the
|
|
335
|
+
// mode resolves lazily behind it.
|
|
336
|
+
const requestProvider = {
|
|
337
|
+
get: async (providerName, ownerDID)=>(await getSurface()).provider.get(providerName, ownerDID),
|
|
338
|
+
set: async (setParams)=>{
|
|
339
|
+
await (await getSurface()).provider.set(setParams);
|
|
340
|
+
},
|
|
341
|
+
delete: async (providerName, ownerDID)=>{
|
|
342
|
+
await (await getSurface()).provider.delete(providerName, ownerDID);
|
|
343
|
+
},
|
|
344
|
+
getCredentialProvenance: async (providerName, ownerDID)=>(await getSurface()).provider.getCredentialProvenance(providerName, ownerDID)
|
|
345
|
+
};
|
|
226
346
|
return {
|
|
227
|
-
getState: (name)=>resolveConnectorState(
|
|
347
|
+
getState: (name)=>resolveConnectorState({
|
|
348
|
+
connectorAPI: requestAPI,
|
|
349
|
+
registry,
|
|
350
|
+
credentialProvider: requestProvider,
|
|
351
|
+
getWorkflowAPI,
|
|
352
|
+
viewerDID: ctx.viewerDID,
|
|
353
|
+
connectorName: name
|
|
354
|
+
}),
|
|
228
355
|
getStates: ()=>{
|
|
229
356
|
const names = registry.list();
|
|
230
|
-
return Promise.all(names.map((name)=>resolveConnectorState(
|
|
357
|
+
return Promise.all(names.map((name)=>resolveConnectorState({
|
|
358
|
+
connectorAPI: requestAPI,
|
|
359
|
+
registry,
|
|
360
|
+
credentialProvider: requestProvider,
|
|
361
|
+
getWorkflowAPI,
|
|
362
|
+
viewerDID: ctx.viewerDID,
|
|
363
|
+
connectorName: name
|
|
364
|
+
})));
|
|
231
365
|
},
|
|
232
|
-
|
|
233
|
-
|
|
366
|
+
// The owner pre-flight resolves against the engine's OWN controller
|
|
367
|
+
// methods, bound to the request tx (`stores`), in both modes. Using
|
|
368
|
+
// `params.db` here would open a second connection inside the
|
|
369
|
+
// startConnectorAuth mutation tx and deadlock single-connection SQLite.
|
|
370
|
+
startAuth: async (args)=>oauthService.startAuth(args, ctx.viewerDID, requestAPI, getControllerMethods(stores)),
|
|
371
|
+
completeAuth: (args)=>oauthService.completeAuth(args, requestProvider, requestAPI),
|
|
234
372
|
triggerSync: async (args)=>{
|
|
235
373
|
if (!registry.has(args.connector)) {
|
|
236
374
|
return {
|
|
@@ -264,7 +402,7 @@ export function createConnectorPlugin(options) {
|
|
|
264
402
|
registry,
|
|
265
403
|
syncEventEmitter: manager,
|
|
266
404
|
stateStore,
|
|
267
|
-
credentialProvider,
|
|
405
|
+
credentialProvider: backgroundCredentialProvider,
|
|
268
406
|
ownerDID,
|
|
269
407
|
stores: params.db,
|
|
270
408
|
boundary: options.defaults?.boundary,
|
|
@@ -272,7 +410,22 @@ export function createConnectorPlugin(options) {
|
|
|
272
410
|
logger,
|
|
273
411
|
mutateDocuments: params.graph.mutateDocuments
|
|
274
412
|
});
|
|
275
|
-
})().catch(()=>{
|
|
413
|
+
})().catch((err)=>{
|
|
414
|
+
// Surface a deferred-run failure instead of swallowing it — the
|
|
415
|
+
// workflow enqueue and the fallback orchestrate both settle here.
|
|
416
|
+
// The emission itself rejects if a subscriber throws, so guard it:
|
|
417
|
+
// an unhandled rejection here could crash a strict host.
|
|
418
|
+
manager.emitSyncEvent({
|
|
419
|
+
type: 'error',
|
|
420
|
+
connectorName,
|
|
421
|
+
error: err instanceof Error ? err.message : String(err)
|
|
422
|
+
}).catch((emitErr)=>{
|
|
423
|
+
logger.warn('connector sync error event delivery failed {connectorName}', {
|
|
424
|
+
connectorName,
|
|
425
|
+
error: emitErr instanceof Error ? emitErr.message : String(emitErr)
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
});
|
|
276
429
|
});
|
|
277
430
|
return {
|
|
278
431
|
status: 'STARTED',
|
|
@@ -280,6 +433,45 @@ export function createConnectorPlugin(options) {
|
|
|
280
433
|
};
|
|
281
434
|
},
|
|
282
435
|
subscribeToSyncEvents: (connector)=>subscribeToConnectorSyncEvents(manager.syncEvents, connector),
|
|
436
|
+
// Recurring-sync control. Viewer-at-enable: the schedule is registered
|
|
437
|
+
// under a viewer and its subject is `${connector}:${ownerDID}` —
|
|
438
|
+
// identical to the manual sync singletonKey, so scheduled and manual
|
|
439
|
+
// runs interlock. Scheduled fires later write with no viewer, via the
|
|
440
|
+
// engine-signed mutateDocuments path the workflow already uses.
|
|
441
|
+
enablePeriodicSync: async (connector, policyOverride)=>{
|
|
442
|
+
const workflowAPI = await getWorkflowAPI();
|
|
443
|
+
if (workflowAPI == null) {
|
|
444
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
445
|
+
}
|
|
446
|
+
const ownerDID = ctx.viewerDID;
|
|
447
|
+
const subjectKey = `${connector}:${ownerDID}`;
|
|
448
|
+
const policy = policyOverride ?? options.defaults?.periodicSyncPolicy ?? DEFAULT_PERIODIC_SYNC_POLICY;
|
|
449
|
+
const { id } = await workflowAPI.scheduleAdaptive(CONNECTOR_SYNC_WORKFLOW, {
|
|
450
|
+
connectorName: connector,
|
|
451
|
+
ownerDID,
|
|
452
|
+
full: false
|
|
453
|
+
}, {
|
|
454
|
+
policy,
|
|
455
|
+
subjectKey
|
|
456
|
+
});
|
|
457
|
+
return workflowAPI.getPeriodicSync(id);
|
|
458
|
+
},
|
|
459
|
+
disablePeriodicSync: async (connector)=>{
|
|
460
|
+
const workflowAPI = await getWorkflowAPI();
|
|
461
|
+
if (workflowAPI == null) {
|
|
462
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
463
|
+
}
|
|
464
|
+
const subjectKey = `${connector}:${ctx.viewerDID}`;
|
|
465
|
+
return workflowAPI.setPeriodicSyncEnabled(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`, false);
|
|
466
|
+
},
|
|
467
|
+
getPeriodicSync: async (connector)=>{
|
|
468
|
+
const workflowAPI = await getWorkflowAPI();
|
|
469
|
+
if (workflowAPI == null) {
|
|
470
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
471
|
+
}
|
|
472
|
+
const subjectKey = `${connector}:${ctx.viewerDID}`;
|
|
473
|
+
return workflowAPI.getPeriodicSync(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`);
|
|
474
|
+
},
|
|
283
475
|
executeAction: async (args, writeDocument)=>{
|
|
284
476
|
try {
|
|
285
477
|
// Bind the blob write to THIS request's transactional provider so
|
|
@@ -295,7 +487,7 @@ export function createConnectorPlugin(options) {
|
|
|
295
487
|
}, {
|
|
296
488
|
stores,
|
|
297
489
|
registry,
|
|
298
|
-
credentialProvider:
|
|
490
|
+
credentialProvider: requestProvider,
|
|
299
491
|
ownerDID: ctx.viewerDID,
|
|
300
492
|
logger,
|
|
301
493
|
writeDocument,
|
|
@@ -327,7 +519,7 @@ export function createConnectorPlugin(options) {
|
|
|
327
519
|
serverDID,
|
|
328
520
|
viewerDID: ctx.viewerDID,
|
|
329
521
|
stores,
|
|
330
|
-
credentialProvider:
|
|
522
|
+
credentialProvider: requestProvider,
|
|
331
523
|
hlc: params.hlc
|
|
332
524
|
})
|
|
333
525
|
};
|
package/lib/oauth.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { MethodRegistry } from '@kokuin/token';
|
|
2
|
+
import type { CredentialProvider, OAuthProviderDefinition } from '@kubun/credential-types';
|
|
2
3
|
import type { Runtime } from '@sozai/runtime';
|
|
3
|
-
import type { ConnectorAPI } from './api.js';
|
|
4
|
+
import type { ConnectorAPI, CredentialAPI } from './api.js';
|
|
4
5
|
import type { ConnectorRegistry } from './registry.js';
|
|
5
6
|
import type { CompleteConnectorAuthInput, CompleteConnectorAuthOutput, StartConnectorAuthInput, StartConnectorAuthOutput } from './schema.js';
|
|
6
7
|
export type OAuthServiceParams = {
|
|
@@ -9,10 +10,11 @@ export type OAuthServiceParams = {
|
|
|
9
10
|
registry: ConnectorRegistry;
|
|
10
11
|
credentialProvider: CredentialProvider;
|
|
11
12
|
connectorAPI: ConnectorAPI;
|
|
13
|
+
credentialAPI?: CredentialAPI;
|
|
12
14
|
};
|
|
13
15
|
export declare class OAuthService {
|
|
14
16
|
#private;
|
|
15
17
|
constructor(params: OAuthServiceParams);
|
|
16
|
-
startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI): Promise<StartConnectorAuthOutput>;
|
|
18
|
+
startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI, preflightMethods?: MethodRegistry): Promise<StartConnectorAuthOutput>;
|
|
17
19
|
completeAuth(args: CompleteConnectorAuthInput, credentialProvider?: CredentialProvider, connectorAPI?: ConnectorAPI): Promise<CompleteConnectorAuthOutput>;
|
|
18
20
|
}
|
package/lib/oauth.js
CHANGED
|
@@ -14,14 +14,18 @@ export class OAuthService {
|
|
|
14
14
|
#providers;
|
|
15
15
|
#registry;
|
|
16
16
|
#connectorAPI;
|
|
17
|
+
#preflightMethods;
|
|
17
18
|
constructor(params){
|
|
18
19
|
this.#credentialProvider = params.credentialProvider;
|
|
19
20
|
this.#runtime = params.runtime;
|
|
20
21
|
this.#providers = params.providers;
|
|
21
22
|
this.#registry = params.registry;
|
|
22
23
|
this.#connectorAPI = params.connectorAPI;
|
|
24
|
+
this.#preflightMethods = params.credentialAPI?.getControllerMethods();
|
|
23
25
|
}
|
|
24
|
-
async startAuth(args, ownerDID, connectorAPI
|
|
26
|
+
async startAuth(args, ownerDID, connectorAPI, // The controller methods the owner pre-flight resolves against; `undefined`
|
|
27
|
+
// falls back to the constructor-captured methods (the direct unit tests).
|
|
28
|
+
preflightMethods) {
|
|
25
29
|
// Prefer the request-scoped API so the pending-auth write joins the mutation
|
|
26
30
|
// transaction; the base API would deadlock a single-connection SQLite tx.
|
|
27
31
|
const api = connectorAPI ?? this.#connectorAPI;
|
|
@@ -45,7 +49,8 @@ export class OAuthService {
|
|
|
45
49
|
// `did:kokuin:` owner's agreement key resolves only through the controller
|
|
46
50
|
// resolver, and asking without it would fail closed and block a flow the
|
|
47
51
|
// mint would have completed.
|
|
48
|
-
|
|
52
|
+
const methods = preflightMethods ?? this.#preflightMethods;
|
|
53
|
+
if (methods != null && !await canWrapTo(ownerWrappableDID, methods)) {
|
|
49
54
|
throw new Error(`Cannot start OAuth for "${args.provider}": nothing can be encrypted to "${ownerWrappableDID}", so the credential's owner could never read it. Pass \`viewerWrappableDID\` — for a did:peer:4 viewer that is the long form, which carries the agreement key.`);
|
|
50
55
|
}
|
|
51
56
|
// Carried to the mint like the owner's wrappable DID; it becomes a
|
package/lib/schema.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ConnectorSyncEventPayload } from '@kubun/connector';
|
|
2
2
|
import type { SchemaExtension } from '@kubun/engine';
|
|
3
|
+
import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
|
|
3
4
|
import type { EventEmitter } from '@sozai/event';
|
|
4
5
|
import type { ConnectorRegistry } from './registry.js';
|
|
5
6
|
import type { SignedDocumentWriter } from './sync/processor.js';
|
|
@@ -164,6 +165,9 @@ export type ConnectorQueryContext = {
|
|
|
164
165
|
provider: string;
|
|
165
166
|
}) => Promise<boolean>;
|
|
166
167
|
connectorWriteGrants?: () => Promise<Array<ConnectorWriteGrant>>;
|
|
168
|
+
enablePeriodicSync?: (connector: string, policyOverride?: AdaptivePolicy) => Promise<unknown>;
|
|
169
|
+
disablePeriodicSync?: (connector: string) => Promise<unknown | null>;
|
|
170
|
+
getPeriodicSync?: (connector: string) => Promise<unknown | null>;
|
|
167
171
|
};
|
|
168
172
|
declare module '@kubun/graphql' {
|
|
169
173
|
interface PluginContextMap {
|
|
@@ -172,6 +176,7 @@ declare module '@kubun/graphql' {
|
|
|
172
176
|
}
|
|
173
177
|
export type ConnectorExtensionConfig = {
|
|
174
178
|
connectors?: Array<string>;
|
|
179
|
+
periodicSync?: boolean;
|
|
175
180
|
};
|
|
176
181
|
export declare function createConnectorSchemaExtension(params: {
|
|
177
182
|
registry?: ConnectorRegistry;
|
package/lib/schema.js
CHANGED
|
@@ -177,6 +177,22 @@ extend type Subscription {
|
|
|
177
177
|
connectorSyncEvents(connector: String): ConnectorSyncEvent!
|
|
178
178
|
}
|
|
179
179
|
`);
|
|
180
|
+
// Recurring-sync control. Gated because it references `PeriodicSync`, which is
|
|
181
|
+
// defined by the co-deployed workflow plugin — referencing it on a connector-only
|
|
182
|
+
// graph would fail schema build with an unknown type.
|
|
183
|
+
const periodicSyncEnabled = params.config.periodicSync === true;
|
|
184
|
+
if (periodicSyncEnabled) {
|
|
185
|
+
sdlParts.push(`
|
|
186
|
+
extend type Query {
|
|
187
|
+
connectorPeriodicSync(connector: String!): PeriodicSync
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
extend type Mutation {
|
|
191
|
+
enableConnectorPeriodicSync(connector: String!, policy: JSON): PeriodicSync!
|
|
192
|
+
disableConnectorPeriodicSync(connector: String!): PeriodicSync
|
|
193
|
+
}
|
|
194
|
+
`);
|
|
195
|
+
}
|
|
180
196
|
// Dynamic per-model action types and mutations from registered connectors
|
|
181
197
|
const dynamicMutationLines = [];
|
|
182
198
|
if (registry != null) {
|
|
@@ -284,6 +300,32 @@ input ConnectorUpdate${modelName}Input {
|
|
|
284
300
|
return conn.disconnectProvider(args);
|
|
285
301
|
}
|
|
286
302
|
};
|
|
303
|
+
// Resolvers for the gated recurring-sync fields. Attached only when the SDL
|
|
304
|
+
// above declared them, since the resolver-attach step rejects a resolver with
|
|
305
|
+
// no matching field. The ownerDID is the viewer, captured by the context factory.
|
|
306
|
+
if (periodicSyncEnabled) {
|
|
307
|
+
queryFields.connectorPeriodicSync = (_source, args, context)=>{
|
|
308
|
+
const conn = requireConnector(context);
|
|
309
|
+
if (conn.getPeriodicSync == null) {
|
|
310
|
+
throw new Error('connector.getPeriodicSync is not available in this context');
|
|
311
|
+
}
|
|
312
|
+
return conn.getPeriodicSync(args.connector);
|
|
313
|
+
};
|
|
314
|
+
mutationFields.enableConnectorPeriodicSync = (_source, args, context)=>{
|
|
315
|
+
const conn = requireConnector(context);
|
|
316
|
+
if (conn.enablePeriodicSync == null) {
|
|
317
|
+
throw new Error('connector.enablePeriodicSync is not available in this context');
|
|
318
|
+
}
|
|
319
|
+
return conn.enablePeriodicSync(args.connector, args.policy);
|
|
320
|
+
};
|
|
321
|
+
mutationFields.disableConnectorPeriodicSync = (_source, args, context)=>{
|
|
322
|
+
const conn = requireConnector(context);
|
|
323
|
+
if (conn.disablePeriodicSync == null) {
|
|
324
|
+
throw new Error('connector.disablePeriodicSync is not available in this context');
|
|
325
|
+
}
|
|
326
|
+
return conn.disablePeriodicSync(args.connector);
|
|
327
|
+
};
|
|
328
|
+
}
|
|
287
329
|
// Dynamic per-model action resolvers
|
|
288
330
|
if (registry != null) {
|
|
289
331
|
for (const connector of registry.getAll()){
|
|
@@ -360,6 +402,17 @@ input ConnectorUpdate${modelName}Input {
|
|
|
360
402
|
};
|
|
361
403
|
return {
|
|
362
404
|
sdl,
|
|
405
|
+
// These write to the workflow store, not the graph, and must return the
|
|
406
|
+
// resulting projection synchronously. Running them inside `mutateGraph`'s
|
|
407
|
+
// write transaction would hold the shared single-connection DB across the
|
|
408
|
+
// workflow-store write and deadlock (as `syncConnector` defers its enqueue to
|
|
409
|
+
// `onCommit` for the same reason).
|
|
410
|
+
...periodicSyncEnabled ? {
|
|
411
|
+
nonTransactionalMutationFields: [
|
|
412
|
+
'enableConnectorPeriodicSync',
|
|
413
|
+
'disableConnectorPeriodicSync'
|
|
414
|
+
]
|
|
415
|
+
} : {},
|
|
363
416
|
resolvers: {
|
|
364
417
|
queryFields,
|
|
365
418
|
mutationFields,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ConnectorSyncEventPayload,
|
|
1
|
+
import type { ConnectorSyncEventPayload, SyncBoundary, SyncStateStore } from '@kubun/connector';
|
|
2
|
+
import type { CredentialProvider } from '@kubun/credential-types';
|
|
2
3
|
import type { StoreProvider } from '@kubun/db';
|
|
3
4
|
import type { Logger } from '@kubun/logger';
|
|
4
5
|
import type { ConnectorRegistry } from '../registry.js';
|