@kubun/plugin-connector 0.14.1 → 0.14.3
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 +9 -0
- package/lib/action.js +9 -1
- package/lib/auth-required.d.ts +11 -0
- package/lib/auth-required.js +17 -0
- package/lib/credential-refresh-bridge.d.ts +11 -0
- package/lib/credential-refresh-bridge.js +27 -0
- package/lib/credential.d.ts +2 -2
- package/lib/credential.js +1 -1
- package/lib/index.d.ts +8 -2
- package/lib/index.js +350 -62
- package/lib/manager.d.ts +71 -3
- package/lib/manager.js +41 -6
- package/lib/oauth.d.ts +5 -1
- package/lib/oauth.js +5 -2
- package/lib/periodic-sync.d.ts +4 -0
- package/lib/periodic-sync.js +12 -0
- package/lib/schema.d.ts +45 -1
- package/lib/schema.js +122 -0
- package/lib/sync/orchestrate.d.ts +11 -1
- package/lib/sync/orchestrate.js +6 -0
- package/lib/sync/workflow.d.ts +11 -0
- package/lib/sync/workflow.js +26 -2
- package/lib/write-grants.d.ts +10 -2
- package/lib/write-grants.js +10 -1
- package/package.json +7 -6
package/lib/index.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { isSigningIdentity } from '@kokuin/token';
|
|
1
|
+
import { isSigningIdentity, normalizeDID } from '@kokuin/token';
|
|
2
2
|
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
6
|
import { createConnectorAPI, createCredentialAPI } from './api.js';
|
|
7
|
-
import {
|
|
7
|
+
import { connectorAuthRequiredPayload } from './auth-required.js';
|
|
8
|
+
import { DBCredentialProvider, RefreshingCredentialProvider } from './credential.js';
|
|
9
|
+
import { bridgeCredentialRefreshEvents } from './credential-refresh-bridge.js';
|
|
8
10
|
import { ConnectorManager } from './manager.js';
|
|
9
11
|
import { OAuthService } from './oauth.js';
|
|
12
|
+
import { periodicSyncSubject } from './periodic-sync.js';
|
|
10
13
|
import { ConnectorRegistry } from './registry.js';
|
|
11
|
-
import { createConnectorSchemaExtension, subscribeToConnectorSyncEvents } from './schema.js';
|
|
14
|
+
import { createConnectorSchemaExtension, subscribeToConnectorLifecycleEvents, subscribeToConnectorSyncEvents } from './schema.js';
|
|
12
15
|
import { orchestrateSync } from './sync/orchestrate.js';
|
|
13
16
|
import { DBSyncStateStore } from './sync/state.js';
|
|
14
17
|
import { CONNECTOR_SYNC_CONCURRENCY, CONNECTOR_SYNC_WORKFLOW, createConnectorSyncWorkflow } from './sync/workflow.js';
|
|
@@ -25,7 +28,7 @@ export { orchestrateSync } from './sync/orchestrate.js';
|
|
|
25
28
|
export { EntityProcessor } from './sync/processor.js';
|
|
26
29
|
export { DBSyncStateStore } from './sync/state.js';
|
|
27
30
|
export { CONNECTOR_SYNC_CONCURRENCY, CONNECTOR_SYNC_WORKFLOW, createConnectorSyncWorkflow } from './sync/workflow.js';
|
|
28
|
-
export { DBCredentialProvider };
|
|
31
|
+
export { DBCredentialProvider, RefreshingCredentialProvider, };
|
|
29
32
|
// 5m after a productive run; idle 15m→6h, error 1m→1h, offline 30s→15m.
|
|
30
33
|
const DEFAULT_PERIODIC_SYNC_POLICY = {
|
|
31
34
|
changed: 300000,
|
|
@@ -42,14 +45,26 @@ const DEFAULT_PERIODIC_SYNC_POLICY = {
|
|
|
42
45
|
max: 900000
|
|
43
46
|
}
|
|
44
47
|
};
|
|
48
|
+
// The engine's own registry has no typed "no such plugin" error — an
|
|
49
|
+
// unregistered `getAPI(name)` throws a plain `Error` with this exact message
|
|
50
|
+
// (packages/engine/src/registry.ts). A message match is the only signal
|
|
51
|
+
// available, so it stays narrow (exact equality, not a substring), and a
|
|
52
|
+
// tripwire test elsewhere pins the engine's message so a future rename fails
|
|
53
|
+
// loudly here instead of silently re-masking real init failures as absence.
|
|
54
|
+
function isWorkflowPluginAbsent(err) {
|
|
55
|
+
return err instanceof Error && err.message === 'API not registered: workflow';
|
|
56
|
+
}
|
|
45
57
|
// Present the standalone local stack as the unified surface: the credential
|
|
46
|
-
// provider for get/set/delete, its credential API for provenance. Service
|
|
47
|
-
// gets provenance as one more procedure, so both modes read it off one
|
|
58
|
+
// provider for get/set/delete/peek, its credential API for provenance. Service
|
|
59
|
+
// mode gets provenance as one more procedure, so both modes read it off one
|
|
60
|
+
// object. The refreshing provider's `peek` is forwarded so a disconnect can test
|
|
61
|
+
// existence without triggering a refresh.
|
|
48
62
|
function withProvenance(provider, api) {
|
|
49
63
|
return {
|
|
50
64
|
get: (providerName, ownerDID)=>provider.get(providerName, ownerDID),
|
|
51
65
|
set: (setParams)=>provider.set(setParams),
|
|
52
66
|
delete: (providerName, ownerDID)=>provider.delete(providerName, ownerDID),
|
|
67
|
+
peek: (providerName, ownerDID)=>provider.peek(providerName, ownerDID),
|
|
53
68
|
getCredentialProvenance: (providerName, ownerDID)=>api.getCredentialProvenance(providerName, ownerDID)
|
|
54
69
|
};
|
|
55
70
|
}
|
|
@@ -62,18 +77,37 @@ function displayStatusFor(status) {
|
|
|
62
77
|
if (status === 'failed') return 'ERROR';
|
|
63
78
|
return 'IDLE';
|
|
64
79
|
}
|
|
65
|
-
async function resolveConnectorState({ connectorAPI, registry, credentialProvider, getWorkflowAPI, viewerDID, connectorName }) {
|
|
80
|
+
async function resolveConnectorState({ connectorAPI, registry, credentialProvider, getWorkflowAPI, viewerDID: rawViewerDID, connectorName }) {
|
|
66
81
|
const connector = registry.get(connectorName);
|
|
67
82
|
if (connector == null) {
|
|
68
83
|
throw new Error(`Connector "${connectorName}" not found`);
|
|
69
84
|
}
|
|
85
|
+
// Credential and sync-state rows are keyed on the normalized owner (a viewer
|
|
86
|
+
// can arrive long-form); the workflow status is keyed on the same subject as
|
|
87
|
+
// the schedule. Fold once so all three reads hit the right rows.
|
|
88
|
+
const viewerDID = normalizeDID(rawViewerDID);
|
|
70
89
|
const syncState = await connectorAPI.getSyncState(connectorName, viewerDID);
|
|
71
90
|
// The durable, lease-fenced truth: the workflow instance status. Preferred over
|
|
72
91
|
// the hand-written syncState.status row, which a zombie handler can transiently
|
|
73
92
|
// regress after crash-recovery. Falls back to that row only with no workflow
|
|
74
93
|
// plugin (local-only sync) or no instance yet.
|
|
75
94
|
let workflowStatus = null;
|
|
76
|
-
|
|
95
|
+
// A status read degrades to the syncState-only fallback below on a broken
|
|
96
|
+
// workflow plugin rather than failing outright — `getStates` fans this out
|
|
97
|
+
// over every registered connector with `Promise.all`, so one broken plugin
|
|
98
|
+
// would otherwise blank the whole list. `getWorkflowAPI` already logs the
|
|
99
|
+
// failure; this stays silent to avoid re-logging on every poll. The one
|
|
100
|
+
// signal kept is `workflowStatusError`: a genuine init failure (a caught
|
|
101
|
+
// rejection) sets it so a consumer can tell degraded-because-broken from
|
|
102
|
+
// degraded-because-absent (a resolved `undefined`, expected in local-only
|
|
103
|
+
// apps), which leaves it null.
|
|
104
|
+
let workflowStatusError = null;
|
|
105
|
+
let workflowAPI;
|
|
106
|
+
try {
|
|
107
|
+
workflowAPI = await getWorkflowAPI();
|
|
108
|
+
} catch {
|
|
109
|
+
workflowStatusError = 'WORKFLOW_UNAVAILABLE';
|
|
110
|
+
}
|
|
77
111
|
if (workflowAPI != null) {
|
|
78
112
|
const instance = await workflowAPI.getCurrentInstance(CONNECTOR_SYNC_WORKFLOW, `${connectorName}:${viewerDID}`);
|
|
79
113
|
if (instance != null) {
|
|
@@ -114,7 +148,8 @@ async function resolveConnectorState({ connectorAPI, registry, credentialProvide
|
|
|
114
148
|
credentialWriterDID,
|
|
115
149
|
lastSyncedAt: null,
|
|
116
150
|
entityCount: null,
|
|
117
|
-
error: null
|
|
151
|
+
error: null,
|
|
152
|
+
workflowStatusError
|
|
118
153
|
};
|
|
119
154
|
}
|
|
120
155
|
return {
|
|
@@ -127,7 +162,8 @@ async function resolveConnectorState({ connectorAPI, registry, credentialProvide
|
|
|
127
162
|
credentialWriterDID,
|
|
128
163
|
lastSyncedAt: syncState.lastSyncedAt ?? null,
|
|
129
164
|
entityCount: syncState.entityCount ?? null,
|
|
130
|
-
error: syncState.error ?? null
|
|
165
|
+
error: syncState.error ?? null,
|
|
166
|
+
workflowStatusError
|
|
131
167
|
};
|
|
132
168
|
}
|
|
133
169
|
// ---- Plugin factory ----
|
|
@@ -209,6 +245,12 @@ export function createConnectorPlugin(options) {
|
|
|
209
245
|
// defaults to params.db for the background (workflow/orchestrate) paths, which
|
|
210
246
|
// run outside any request transaction. A request path passes its own tx.
|
|
211
247
|
let standaloneStack;
|
|
248
|
+
// Shared single-flight map for the standalone refresh decorator, one per plugin
|
|
249
|
+
// instance. The cached background stack and each per-request stack build their
|
|
250
|
+
// own decorator, but they all refresh the same stored credentials; sharing this
|
|
251
|
+
// map collapses the concurrent sync runs' refreshes of one credential to a
|
|
252
|
+
// single token exchange and a single emitted outcome.
|
|
253
|
+
const refreshInFlight = new Map();
|
|
212
254
|
const getStandaloneStack = (stores = params.db)=>{
|
|
213
255
|
// The background stack is cached on params.db; a per-request tx builds a
|
|
214
256
|
// fresh stack so its reads and writes join that transaction.
|
|
@@ -220,13 +262,32 @@ export function createConnectorPlugin(options) {
|
|
|
220
262
|
serverWrappableDID,
|
|
221
263
|
getControllerMethods
|
|
222
264
|
});
|
|
265
|
+
// The DB provider is storage-only; the decorator adds transparent
|
|
266
|
+
// refresh-on-read over it, writing a refreshed token back through the same
|
|
267
|
+
// storage provider so it joins the request's transaction.
|
|
268
|
+
const provider = new RefreshingCredentialProvider({
|
|
269
|
+
inner: new DBCredentialProvider({
|
|
270
|
+
api
|
|
271
|
+
}),
|
|
272
|
+
runtime: params.runtime,
|
|
273
|
+
providers,
|
|
274
|
+
refreshInFlight
|
|
275
|
+
});
|
|
276
|
+
// Surface this decorator's refresh outcomes as connector lifecycle events.
|
|
277
|
+
// Each stack builds its own decorator with its own emitter, so the bridge
|
|
278
|
+
// is attached per construction; a per-request instance is short-lived, the
|
|
279
|
+
// background instance is cached. Deferring the emit through this stack's own
|
|
280
|
+
// `stores.onCommit` keeps a per-request refresh's lifecycle listener out of
|
|
281
|
+
// the mutation transaction that owns the single DB connection.
|
|
282
|
+
bridgeCredentialRefreshEvents({
|
|
283
|
+
source: provider.refreshEvents,
|
|
284
|
+
manager,
|
|
285
|
+
registry,
|
|
286
|
+
defer: (fn)=>stores.onCommit(fn)
|
|
287
|
+
});
|
|
223
288
|
const stack = {
|
|
224
289
|
api,
|
|
225
|
-
provider
|
|
226
|
-
api,
|
|
227
|
-
runtime: params.runtime,
|
|
228
|
-
providers
|
|
229
|
-
})
|
|
290
|
+
provider
|
|
230
291
|
};
|
|
231
292
|
if (stores === params.db) standaloneStack = stack;
|
|
232
293
|
return stack;
|
|
@@ -237,6 +298,8 @@ export function createConnectorPlugin(options) {
|
|
|
237
298
|
const resolveCredentialSurface = async (stores)=>{
|
|
238
299
|
const serviceProvider = await credentialServiceProviderPromise;
|
|
239
300
|
if (serviceProvider != null) {
|
|
301
|
+
// The remote service provider has no `peek`; a consumer falls back to
|
|
302
|
+
// its (non-local-refreshing) `get`.
|
|
240
303
|
return {
|
|
241
304
|
provider: serviceProvider
|
|
242
305
|
};
|
|
@@ -270,6 +333,15 @@ export function createConnectorPlugin(options) {
|
|
|
270
333
|
logger,
|
|
271
334
|
defaults: options.defaults
|
|
272
335
|
});
|
|
336
|
+
// Emit `connector:authRequired` for a genuinely absent credential. The deep
|
|
337
|
+
// sync sites (workflow, orchestrate, action) only know `{ provider, ownerDID }`;
|
|
338
|
+
// the connector mapping, owner normalization and `reason: 'absent'` are folded
|
|
339
|
+
// in here. Timing is the caller's: the workflow and orchestrate sites emit
|
|
340
|
+
// directly (matching their sibling sync events); the action site defers via
|
|
341
|
+
// `stores.onCommit` because it runs inside the request's mutation transaction.
|
|
342
|
+
const emitAuthRequiredNow = (provider, ownerDID)=>{
|
|
343
|
+
manager.emitLifecycle('connector:authRequired', connectorAuthRequiredPayload(registry, provider, ownerDID));
|
|
344
|
+
};
|
|
273
345
|
const oauthService = new OAuthService({
|
|
274
346
|
runtime: params.runtime,
|
|
275
347
|
providers,
|
|
@@ -288,7 +360,8 @@ export function createConnectorPlugin(options) {
|
|
|
288
360
|
stores: params.db,
|
|
289
361
|
mutateDocuments: params.graph.mutateDocuments,
|
|
290
362
|
boundary: options.defaults?.boundary,
|
|
291
|
-
logger
|
|
363
|
+
logger,
|
|
364
|
+
emitAuthRequired: ({ provider, ownerDID })=>emitAuthRequiredNow(provider, ownerDID)
|
|
292
365
|
});
|
|
293
366
|
let workflowAPIPromise;
|
|
294
367
|
const getWorkflowAPI = ()=>{
|
|
@@ -299,20 +372,198 @@ export function createConnectorPlugin(options) {
|
|
|
299
372
|
});
|
|
300
373
|
api.register(connectorSyncWorkflow.definition);
|
|
301
374
|
return api;
|
|
302
|
-
}).catch(()=>
|
|
375
|
+
}).catch((err)=>{
|
|
376
|
+
if (isWorkflowPluginAbsent(err)) return undefined;
|
|
377
|
+
// A present-but-broken plugin, not a genuinely absent one: don't
|
|
378
|
+
// cache this rejection as a permanent no-op — reset so a later
|
|
379
|
+
// call rebuilds the API instead of replaying a wedged failure
|
|
380
|
+
// (or a transient one that has since cleared).
|
|
381
|
+
workflowAPIPromise = undefined;
|
|
382
|
+
logger.warn('workflow API resolution failed {error}', {
|
|
383
|
+
error: err instanceof Error ? err.message : String(err)
|
|
384
|
+
});
|
|
385
|
+
throw err;
|
|
386
|
+
});
|
|
303
387
|
}
|
|
304
388
|
return workflowAPIPromise;
|
|
305
389
|
};
|
|
390
|
+
// Register the connector-sync workflow eagerly at startup rather than on the
|
|
391
|
+
// first sync/schedule call. `getAPI` resolves only after the engine closes its
|
|
392
|
+
// plugin gate (every plugin's API registered), so this settles regardless of
|
|
393
|
+
// plugin order — and before any GraphQL request. Without it, a generic
|
|
394
|
+
// `periodicSync` query/mutation arriving first after a restart would see the
|
|
395
|
+
// workflow unregistered and bypass its managed-schedule protection.
|
|
396
|
+
void getWorkflowAPI().catch(()=>undefined);
|
|
397
|
+
// Owner-bound periodic-sync control. The public (viewer) path and the
|
|
398
|
+
// auto-enable-on-auth subscriber both route here so the schedule is keyed on
|
|
399
|
+
// the credential owner's normalized DID — matching the manual-sync singleton
|
|
400
|
+
// so scheduled and manual runs interlock. The emit is direct (not deferred):
|
|
401
|
+
// the sibling workflow call is already direct here, and the emit touches no
|
|
402
|
+
// store.
|
|
403
|
+
const enablePeriodicSyncForOwner = async (connector, ownerDID, policyOverride, scheduleOpts)=>{
|
|
404
|
+
if (!registry.has(connector)) {
|
|
405
|
+
throw new Error(`unknown connector: ${connector}`);
|
|
406
|
+
}
|
|
407
|
+
const workflowAPI = await getWorkflowAPI();
|
|
408
|
+
if (workflowAPI == null) {
|
|
409
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
410
|
+
}
|
|
411
|
+
const { owner, subjectKey } = periodicSyncSubject(connector, ownerDID);
|
|
412
|
+
const policy = policyOverride ?? options.defaults?.periodicSyncPolicy ?? DEFAULT_PERIODIC_SYNC_POLICY;
|
|
413
|
+
const { id } = await workflowAPI.scheduleAdaptive(CONNECTOR_SYNC_WORKFLOW, {
|
|
414
|
+
connectorName: connector,
|
|
415
|
+
ownerDID: owner,
|
|
416
|
+
full: false
|
|
417
|
+
}, {
|
|
418
|
+
policy,
|
|
419
|
+
subjectKey,
|
|
420
|
+
reactivate: scheduleOpts?.reactivate
|
|
421
|
+
});
|
|
422
|
+
const result = await workflowAPI.getPeriodicSync(id);
|
|
423
|
+
// `reactivate: false` preserves a user-disabled row, so only announce
|
|
424
|
+
// `enabled` when the schedule actually is — a re-auth of a paused connector
|
|
425
|
+
// must not emit a state change that did not happen.
|
|
426
|
+
if (result?.enabled) {
|
|
427
|
+
manager.emitLifecycle('periodicSync:enabled', {
|
|
428
|
+
connector,
|
|
429
|
+
ownerDID: owner,
|
|
430
|
+
subjectKey
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return result;
|
|
434
|
+
};
|
|
435
|
+
const disablePeriodicSyncForOwner = async (connector, ownerDID)=>{
|
|
436
|
+
if (!registry.has(connector)) {
|
|
437
|
+
throw new Error(`unknown connector: ${connector}`);
|
|
438
|
+
}
|
|
439
|
+
const workflowAPI = await getWorkflowAPI();
|
|
440
|
+
if (workflowAPI == null) {
|
|
441
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
442
|
+
}
|
|
443
|
+
const { owner, subjectKey } = periodicSyncSubject(connector, ownerDID);
|
|
444
|
+
const result = await workflowAPI.setPeriodicSyncEnabled(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`, false);
|
|
445
|
+
// A null result means no such schedule existed — nothing changed, so no
|
|
446
|
+
// `disabled` event.
|
|
447
|
+
if (result != null) {
|
|
448
|
+
manager.emitLifecycle('periodicSync:disabled', {
|
|
449
|
+
connector,
|
|
450
|
+
ownerDID: owner,
|
|
451
|
+
subjectKey
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return result;
|
|
455
|
+
};
|
|
456
|
+
// Arm-if-absent recovery for one owner: for every connector this owner holds
|
|
457
|
+
// a credential for, create a periodic schedule when none exists yet. An
|
|
458
|
+
// existing schedule — enabled or user-disabled — is left untouched, so this
|
|
459
|
+
// is safe to run repeatedly and never rewrites a live schedule's `next_run`
|
|
460
|
+
// or re-announces an already-enabled one. It is the shared body of both the
|
|
461
|
+
// auth-event subscriber and the boot sweep: arming the just-authenticated
|
|
462
|
+
// connectors and retrying any earlier arm that failed transiently for the
|
|
463
|
+
// same owner. Failures are isolated per connector and never propagate.
|
|
464
|
+
const reconcileOwnerNow = async (ownerDID)=>{
|
|
465
|
+
const workflowAPI = await getWorkflowAPI();
|
|
466
|
+
if (workflowAPI == null) return;
|
|
467
|
+
for (const connector of registry.getAll()){
|
|
468
|
+
if (connector.auth.provider === 'device') continue;
|
|
469
|
+
try {
|
|
470
|
+
const credential = await backgroundCredentialProvider.get(connector.auth.provider, ownerDID);
|
|
471
|
+
if (credential == null) continue;
|
|
472
|
+
const { subjectKey } = periodicSyncSubject(connector.name, ownerDID);
|
|
473
|
+
const existing = await workflowAPI.getPeriodicSync(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`);
|
|
474
|
+
if (existing != null) continue;
|
|
475
|
+
await enablePeriodicSyncForOwner(connector.name, ownerDID, undefined, {
|
|
476
|
+
reactivate: false
|
|
477
|
+
});
|
|
478
|
+
} catch (err) {
|
|
479
|
+
logger.debug('auto-arm reconcile skipped {connector} {ownerDID}', {
|
|
480
|
+
connector: connector.name,
|
|
481
|
+
ownerDID,
|
|
482
|
+
error: err instanceof Error ? err.message : String(err)
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
// Serialize reconciles per owner. The arm-if-absent check (getPeriodicSync)
|
|
488
|
+
// and the arm are two steps, so two reconciles of the same owner running
|
|
489
|
+
// concurrently — the boot sweep racing an auth event, or two auth events in
|
|
490
|
+
// quick succession — could both read a subject as absent and both arm it. The
|
|
491
|
+
// atomic upsert stops that becoming a PK error, but the loser would still
|
|
492
|
+
// rewrite `next_run` and emit a second `periodicSync:enabled`, breaking the
|
|
493
|
+
// no-touch guarantee above. Chaining per normalized owner makes the later run
|
|
494
|
+
// observe the schedule the earlier one created, so it skips.
|
|
495
|
+
const reconcileChains = new Map();
|
|
496
|
+
const reconcileOwner = (ownerDID)=>{
|
|
497
|
+
const key = normalizeDID(ownerDID);
|
|
498
|
+
const prev = reconcileChains.get(key) ?? Promise.resolve();
|
|
499
|
+
const next = prev.catch(()=>{}).then(()=>reconcileOwnerNow(ownerDID));
|
|
500
|
+
reconcileChains.set(key, next);
|
|
501
|
+
void next.finally(()=>{
|
|
502
|
+
// Only the tail clears the entry; a newer chained run keeps it alive.
|
|
503
|
+
if (reconcileChains.get(key) === next) reconcileChains.delete(key);
|
|
504
|
+
});
|
|
505
|
+
return next;
|
|
506
|
+
};
|
|
507
|
+
// Opt-in: reconcile the authenticated owner on every `connector:authenticated`.
|
|
508
|
+
// Subscribed on `lifecycleEvents` (the merged, tagged channel), not the
|
|
509
|
+
// per-event `events` channel forwarded to external plugin consumers as
|
|
510
|
+
// `connectorPluginAPI.events` — this subscriber is the connector plugin's own
|
|
511
|
+
// internal wiring, not a consumer of its public surface, so it stays decoupled
|
|
512
|
+
// from whatever external listeners do with `events`. Reconciling the whole
|
|
513
|
+
// owner (not just `event.connectors`) means a connector whose arm failed at an
|
|
514
|
+
// earlier auth is recovered the next time the owner authenticates anything.
|
|
515
|
+
// Fire-and-forget and best-effort: any failure is caught inside
|
|
516
|
+
// `reconcileOwner` rather than thrown into the emitter's listener.
|
|
517
|
+
if (options.autoEnablePeriodicSync) {
|
|
518
|
+
manager.lifecycleEvents.on('lifecycle', (event)=>{
|
|
519
|
+
if (event.type !== 'connector:authenticated') return;
|
|
520
|
+
reconcileOwner(event.ownerDID).catch((err)=>{
|
|
521
|
+
logger.debug('auto-arm reconcile (on auth) failed {ownerDID}', {
|
|
522
|
+
ownerDID: event.ownerDID,
|
|
523
|
+
error: err instanceof Error ? err.message : String(err)
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
// Boot sweep (standalone only): on init, re-arm any owner authenticated in a
|
|
528
|
+
// previous run whose schedule is missing — the restart/mount recovery for a
|
|
529
|
+
// transient arm failure that no later auth would otherwise reach. It runs
|
|
530
|
+
// once, off the request path, so it is fire-and-forget and must not block or
|
|
531
|
+
// fail init. Service mode has no local credential store to enumerate and its
|
|
532
|
+
// remote backend owns its own reconcile, so the sweep is skipped there; the
|
|
533
|
+
// auth-event path above still recovers in both modes.
|
|
534
|
+
void (async ()=>{
|
|
535
|
+
const serviceProvider = await credentialServiceProviderPromise;
|
|
536
|
+
if (serviceProvider != null) return;
|
|
537
|
+
const { api } = getStandaloneStack(params.db);
|
|
538
|
+
const providers = new Set(registry.getAll().map((connector)=>connector.auth.provider).filter((provider)=>provider !== 'device'));
|
|
539
|
+
const owners = new Set();
|
|
540
|
+
for (const provider of providers){
|
|
541
|
+
for (const owner of (await api.listSubjects(provider)))owners.add(owner);
|
|
542
|
+
}
|
|
543
|
+
for (const owner of owners)await reconcileOwner(owner);
|
|
544
|
+
})().catch((err)=>{
|
|
545
|
+
logger.debug('auto-arm boot sweep failed {error}', {
|
|
546
|
+
error: err instanceof Error ? err.message : String(err)
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
}
|
|
306
550
|
// Process-local dedup for the no-workflow-plugin fallback path only (the
|
|
307
551
|
// workflow singleton guards the primary path).
|
|
308
552
|
const orchestrateInFlight = new Set();
|
|
553
|
+
// The exposed API: storage surface plus a listen-only view of the live
|
|
554
|
+
// manager's event channel. Consumers subscribe through `events`; the emit
|
|
555
|
+
// side stays with the manager, which internal producers hold directly.
|
|
556
|
+
const connectorPluginAPI = {
|
|
557
|
+
...connectorAPI,
|
|
558
|
+
events: manager.events
|
|
559
|
+
};
|
|
309
560
|
return {
|
|
310
561
|
name: 'connector',
|
|
311
562
|
schemaExtension: (config)=>createConnectorSchemaExtension({
|
|
312
563
|
registry,
|
|
313
564
|
config: config
|
|
314
565
|
}),
|
|
315
|
-
api:
|
|
566
|
+
api: connectorPluginAPI,
|
|
316
567
|
createContextFactory: ()=>{
|
|
317
568
|
return (ctx, stores)=>{
|
|
318
569
|
// Per-request connector API backed by the request's StoreProvider
|
|
@@ -332,7 +583,10 @@ export function createConnectorPlugin(options) {
|
|
|
332
583
|
};
|
|
333
584
|
// A base provider that defers to the resolved surface, so synchronous
|
|
334
585
|
// consumers (write-grants, executeAction) get one object while the
|
|
335
|
-
// mode resolves lazily behind it.
|
|
586
|
+
// mode resolves lazily behind it. `peek` uses the surface's
|
|
587
|
+
// non-refreshing read when available, falling back to `get` (service
|
|
588
|
+
// mode has no local peek), so a disconnect's existence check never
|
|
589
|
+
// triggers a standalone refresh.
|
|
336
590
|
const requestProvider = {
|
|
337
591
|
get: async (providerName, ownerDID)=>(await getSurface()).provider.get(providerName, ownerDID),
|
|
338
592
|
set: async (setParams)=>{
|
|
@@ -341,6 +595,10 @@ export function createConnectorPlugin(options) {
|
|
|
341
595
|
delete: async (providerName, ownerDID)=>{
|
|
342
596
|
await (await getSurface()).provider.delete(providerName, ownerDID);
|
|
343
597
|
},
|
|
598
|
+
peek: async (providerName, ownerDID)=>{
|
|
599
|
+
const { provider } = await getSurface();
|
|
600
|
+
return provider.peek != null ? provider.peek(providerName, ownerDID) : provider.get(providerName, ownerDID);
|
|
601
|
+
},
|
|
344
602
|
getCredentialProvenance: async (providerName, ownerDID)=>(await getSurface()).provider.getCredentialProvenance(providerName, ownerDID)
|
|
345
603
|
};
|
|
346
604
|
return {
|
|
@@ -368,7 +626,27 @@ export function createConnectorPlugin(options) {
|
|
|
368
626
|
// `params.db` here would open a second connection inside the
|
|
369
627
|
// startConnectorAuth mutation tx and deadlock single-connection SQLite.
|
|
370
628
|
startAuth: async (args)=>oauthService.startAuth(args, ctx.viewerDID, requestAPI, getControllerMethods(stores)),
|
|
371
|
-
completeAuth: (args)=>
|
|
629
|
+
completeAuth: async (args)=>{
|
|
630
|
+
const { output, ownerDID } = await oauthService.completeAuth(args, requestProvider, requestAPI);
|
|
631
|
+
// Announce the authorization to lifecycle consumers (e.g. auto-enable),
|
|
632
|
+
// carrying the provider's connectors and the owner in its normalized
|
|
633
|
+
// (short) form — the same folding the credential row is keyed on.
|
|
634
|
+
const connectors = registry.getAll().filter((connector)=>connector.auth.provider === output.providerName).map((connector)=>connector.name);
|
|
635
|
+
const normalizedOwnerDID = normalizeDID(ownerDID);
|
|
636
|
+
// After the mutation commits, never inside it: a subscriber that
|
|
637
|
+
// schedules a sync writes through the top-level db, which would
|
|
638
|
+
// deadlock the single-connection tx still held here (as triggerSync
|
|
639
|
+
// defers its enqueue below for the same reason).
|
|
640
|
+
stores.onCommit(()=>{
|
|
641
|
+
manager.emitLifecycle('connector:authenticated', {
|
|
642
|
+
provider: output.providerName,
|
|
643
|
+
connectors,
|
|
644
|
+
ownerDID: normalizedOwnerDID,
|
|
645
|
+
scopes: output.scopes
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
return output;
|
|
649
|
+
},
|
|
372
650
|
triggerSync: async (args)=>{
|
|
373
651
|
if (!registry.has(args.connector)) {
|
|
374
652
|
return {
|
|
@@ -377,7 +655,10 @@ export function createConnectorPlugin(options) {
|
|
|
377
655
|
};
|
|
378
656
|
}
|
|
379
657
|
const connectorName = args.connector;
|
|
380
|
-
|
|
658
|
+
// Fold the owner to its short form so the singleton subject matches
|
|
659
|
+
// the periodic schedule's, and the workflow reads the credential and
|
|
660
|
+
// sync-state rows keyed on the normalized owner.
|
|
661
|
+
const ownerDID = normalizeDID(ctx.viewerDID);
|
|
381
662
|
const full = args.full ?? false;
|
|
382
663
|
// Defer to after the mutation commits — both the workflow enqueue
|
|
383
664
|
// (a store insert) and the fallback background run use the top-level
|
|
@@ -408,22 +689,18 @@ export function createConnectorPlugin(options) {
|
|
|
408
689
|
boundary: options.defaults?.boundary,
|
|
409
690
|
inFlight: orchestrateInFlight,
|
|
410
691
|
logger,
|
|
411
|
-
mutateDocuments: params.graph.mutateDocuments
|
|
692
|
+
mutateDocuments: params.graph.mutateDocuments,
|
|
693
|
+
emitAuthRequired: ({ provider, ownerDID: owner })=>emitAuthRequiredNow(provider, owner)
|
|
412
694
|
});
|
|
413
695
|
})().catch((err)=>{
|
|
414
696
|
// Surface a deferred-run failure instead of swallowing it — the
|
|
415
697
|
// workflow enqueue and the fallback orchestrate both settle here.
|
|
416
|
-
// The
|
|
417
|
-
//
|
|
698
|
+
// The emit is failure-isolated (a throwing subscriber is logged
|
|
699
|
+
// by the emitter), so it needs no guard of its own.
|
|
418
700
|
manager.emitSyncEvent({
|
|
419
701
|
type: 'error',
|
|
420
702
|
connectorName,
|
|
421
703
|
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
704
|
});
|
|
428
705
|
});
|
|
429
706
|
});
|
|
@@ -433,43 +710,30 @@ export function createConnectorPlugin(options) {
|
|
|
433
710
|
};
|
|
434
711
|
},
|
|
435
712
|
subscribeToSyncEvents: (connector)=>subscribeToConnectorSyncEvents(manager.syncEvents, connector),
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
713
|
+
// Viewer-scoped façade over the manager's INTERNAL merged lifecycle
|
|
714
|
+
// channel: the viewer captured here (folded to its short form, as
|
|
715
|
+
// every lifecycle payload's owner is) gates delivery, so one viewer
|
|
716
|
+
// never observes another's auth activity.
|
|
717
|
+
subscribeToLifecycleEvents: (connector)=>subscribeToConnectorLifecycleEvents(manager.lifecycleEvents, {
|
|
718
|
+
viewerDID: normalizeDID(ctx.viewerDID),
|
|
719
|
+
connector
|
|
720
|
+
}),
|
|
721
|
+
// Recurring-sync control. The schedule binds to the viewer as the
|
|
722
|
+
// credential owner; its subject is `${connector}:${normalizeDID(owner)}`
|
|
723
|
+
// — identical to the manual sync singletonKey, so scheduled and manual
|
|
439
724
|
// runs interlock. Scheduled fires later write with no viewer, via the
|
|
440
725
|
// engine-signed mutateDocuments path the workflow already uses.
|
|
441
|
-
enablePeriodicSync:
|
|
442
|
-
|
|
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
|
-
},
|
|
726
|
+
enablePeriodicSync: (connector, policyOverride)=>enablePeriodicSyncForOwner(connector, ctx.viewerDID, policyOverride),
|
|
727
|
+
disablePeriodicSync: (connector)=>disablePeriodicSyncForOwner(connector, ctx.viewerDID),
|
|
467
728
|
getPeriodicSync: async (connector)=>{
|
|
729
|
+
if (!registry.has(connector)) {
|
|
730
|
+
throw new Error(`unknown connector: ${connector}`);
|
|
731
|
+
}
|
|
468
732
|
const workflowAPI = await getWorkflowAPI();
|
|
469
733
|
if (workflowAPI == null) {
|
|
470
734
|
throw new Error('workflow plugin required for periodic sync');
|
|
471
735
|
}
|
|
472
|
-
const subjectKey =
|
|
736
|
+
const { subjectKey } = periodicSyncSubject(connector, ctx.viewerDID);
|
|
473
737
|
return workflowAPI.getPeriodicSync(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`);
|
|
474
738
|
},
|
|
475
739
|
executeAction: async (args, writeDocument)=>{
|
|
@@ -488,10 +752,18 @@ export function createConnectorPlugin(options) {
|
|
|
488
752
|
stores,
|
|
489
753
|
registry,
|
|
490
754
|
credentialProvider: requestProvider,
|
|
491
|
-
|
|
755
|
+
// The credential row is keyed on the normalized owner.
|
|
756
|
+
ownerDID: normalizeDID(ctx.viewerDID),
|
|
492
757
|
logger,
|
|
493
758
|
writeDocument,
|
|
494
|
-
writeAttachment
|
|
759
|
+
writeAttachment,
|
|
760
|
+
// The action runs inside the request's mutation transaction;
|
|
761
|
+
// defer the emit past commit so a subscriber that schedules a
|
|
762
|
+
// sync does not deadlock the single-connection tx still held
|
|
763
|
+
// here (same guard as completeAuth and onCredentialRemoved).
|
|
764
|
+
emitAuthRequired: ({ provider, ownerDID })=>{
|
|
765
|
+
stores.onCommit(()=>emitAuthRequiredNow(provider, ownerDID));
|
|
766
|
+
}
|
|
495
767
|
});
|
|
496
768
|
return {
|
|
497
769
|
documentID: result.documentID,
|
|
@@ -520,7 +792,23 @@ export function createConnectorPlugin(options) {
|
|
|
520
792
|
viewerDID: ctx.viewerDID,
|
|
521
793
|
stores,
|
|
522
794
|
credentialProvider: requestProvider,
|
|
523
|
-
hlc: params.hlc
|
|
795
|
+
hlc: params.hlc,
|
|
796
|
+
// Announce a removed credential to lifecycle consumers, carrying the
|
|
797
|
+
// provider's connectors and the owner in normalized (short) form.
|
|
798
|
+
// After the mutation commits, never inside it: a subscriber that
|
|
799
|
+
// schedules work writes through the top-level db, which would
|
|
800
|
+
// deadlock the single-connection tx still held here (same guard as
|
|
801
|
+
// completeAuth).
|
|
802
|
+
onCredentialRemoved: ({ provider, ownerDID })=>{
|
|
803
|
+
const connectors = registry.getAll().filter((connector)=>connector.auth.provider === provider).map((connector)=>connector.name);
|
|
804
|
+
stores.onCommit(()=>{
|
|
805
|
+
manager.emitLifecycle('connector:deauthenticated', {
|
|
806
|
+
provider,
|
|
807
|
+
connectors,
|
|
808
|
+
ownerDID: normalizeDID(ownerDID)
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
}
|
|
524
812
|
})
|
|
525
813
|
};
|
|
526
814
|
};
|