@kubun/plugin-connector 0.14.0 → 0.14.2
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 +293 -56
- 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 +118 -76
- 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 +10 -10
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,132 @@ 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
|
+
// Opt-in: arm periodic sync for the authenticated owner on every
|
|
457
|
+
// `connector:authenticated`. Subscribed on `lifecycleEvents` (the merged,
|
|
458
|
+
// tagged channel), not the per-event `events` channel forwarded to
|
|
459
|
+
// external plugin consumers as `connectorPluginAPI.events` — this
|
|
460
|
+
// subscriber is the connector plugin's own internal wiring, not a
|
|
461
|
+
// consumer of its public surface, so it stays decoupled from whatever
|
|
462
|
+
// external listeners do with `events`. `reactivate: false` so a schedule
|
|
463
|
+
// the user explicitly disabled is not silently re-armed by a later
|
|
464
|
+
// re-authentication; an absent schedule is still created enabled. Fire-
|
|
465
|
+
// and-forget and best-effort: a missing workflow plugin (or any other
|
|
466
|
+
// failure) is caught here rather than thrown into the emitter's listener,
|
|
467
|
+
// which would otherwise only be logged and swallowed.
|
|
468
|
+
if (options.autoEnablePeriodicSync) {
|
|
469
|
+
manager.lifecycleEvents.on('lifecycle', (event)=>{
|
|
470
|
+
if (event.type !== 'connector:authenticated') return;
|
|
471
|
+
for (const connector of event.connectors){
|
|
472
|
+
enablePeriodicSyncForOwner(connector, event.ownerDID, undefined, {
|
|
473
|
+
reactivate: false
|
|
474
|
+
}).catch((err)=>{
|
|
475
|
+
logger.debug('auto-enable periodic sync skipped {connector} {ownerDID}', {
|
|
476
|
+
connector,
|
|
477
|
+
ownerDID: event.ownerDID,
|
|
478
|
+
error: err instanceof Error ? err.message : String(err)
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}
|
|
306
484
|
// Process-local dedup for the no-workflow-plugin fallback path only (the
|
|
307
485
|
// workflow singleton guards the primary path).
|
|
308
486
|
const orchestrateInFlight = new Set();
|
|
487
|
+
// The exposed API: storage surface plus a listen-only view of the live
|
|
488
|
+
// manager's event channel. Consumers subscribe through `events`; the emit
|
|
489
|
+
// side stays with the manager, which internal producers hold directly.
|
|
490
|
+
const connectorPluginAPI = {
|
|
491
|
+
...connectorAPI,
|
|
492
|
+
events: manager.events
|
|
493
|
+
};
|
|
309
494
|
return {
|
|
310
495
|
name: 'connector',
|
|
311
496
|
schemaExtension: (config)=>createConnectorSchemaExtension({
|
|
312
497
|
registry,
|
|
313
498
|
config: config
|
|
314
499
|
}),
|
|
315
|
-
api:
|
|
500
|
+
api: connectorPluginAPI,
|
|
316
501
|
createContextFactory: ()=>{
|
|
317
502
|
return (ctx, stores)=>{
|
|
318
503
|
// Per-request connector API backed by the request's StoreProvider
|
|
@@ -332,7 +517,10 @@ export function createConnectorPlugin(options) {
|
|
|
332
517
|
};
|
|
333
518
|
// A base provider that defers to the resolved surface, so synchronous
|
|
334
519
|
// consumers (write-grants, executeAction) get one object while the
|
|
335
|
-
// mode resolves lazily behind it.
|
|
520
|
+
// mode resolves lazily behind it. `peek` uses the surface's
|
|
521
|
+
// non-refreshing read when available, falling back to `get` (service
|
|
522
|
+
// mode has no local peek), so a disconnect's existence check never
|
|
523
|
+
// triggers a standalone refresh.
|
|
336
524
|
const requestProvider = {
|
|
337
525
|
get: async (providerName, ownerDID)=>(await getSurface()).provider.get(providerName, ownerDID),
|
|
338
526
|
set: async (setParams)=>{
|
|
@@ -341,6 +529,10 @@ export function createConnectorPlugin(options) {
|
|
|
341
529
|
delete: async (providerName, ownerDID)=>{
|
|
342
530
|
await (await getSurface()).provider.delete(providerName, ownerDID);
|
|
343
531
|
},
|
|
532
|
+
peek: async (providerName, ownerDID)=>{
|
|
533
|
+
const { provider } = await getSurface();
|
|
534
|
+
return provider.peek != null ? provider.peek(providerName, ownerDID) : provider.get(providerName, ownerDID);
|
|
535
|
+
},
|
|
344
536
|
getCredentialProvenance: async (providerName, ownerDID)=>(await getSurface()).provider.getCredentialProvenance(providerName, ownerDID)
|
|
345
537
|
};
|
|
346
538
|
return {
|
|
@@ -368,7 +560,27 @@ export function createConnectorPlugin(options) {
|
|
|
368
560
|
// `params.db` here would open a second connection inside the
|
|
369
561
|
// startConnectorAuth mutation tx and deadlock single-connection SQLite.
|
|
370
562
|
startAuth: async (args)=>oauthService.startAuth(args, ctx.viewerDID, requestAPI, getControllerMethods(stores)),
|
|
371
|
-
completeAuth: (args)=>
|
|
563
|
+
completeAuth: async (args)=>{
|
|
564
|
+
const { output, ownerDID } = await oauthService.completeAuth(args, requestProvider, requestAPI);
|
|
565
|
+
// Announce the authorization to lifecycle consumers (e.g. auto-enable),
|
|
566
|
+
// carrying the provider's connectors and the owner in its normalized
|
|
567
|
+
// (short) form — the same folding the credential row is keyed on.
|
|
568
|
+
const connectors = registry.getAll().filter((connector)=>connector.auth.provider === output.providerName).map((connector)=>connector.name);
|
|
569
|
+
const normalizedOwnerDID = normalizeDID(ownerDID);
|
|
570
|
+
// After the mutation commits, never inside it: a subscriber that
|
|
571
|
+
// schedules a sync writes through the top-level db, which would
|
|
572
|
+
// deadlock the single-connection tx still held here (as triggerSync
|
|
573
|
+
// defers its enqueue below for the same reason).
|
|
574
|
+
stores.onCommit(()=>{
|
|
575
|
+
manager.emitLifecycle('connector:authenticated', {
|
|
576
|
+
provider: output.providerName,
|
|
577
|
+
connectors,
|
|
578
|
+
ownerDID: normalizedOwnerDID,
|
|
579
|
+
scopes: output.scopes
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
return output;
|
|
583
|
+
},
|
|
372
584
|
triggerSync: async (args)=>{
|
|
373
585
|
if (!registry.has(args.connector)) {
|
|
374
586
|
return {
|
|
@@ -377,7 +589,10 @@ export function createConnectorPlugin(options) {
|
|
|
377
589
|
};
|
|
378
590
|
}
|
|
379
591
|
const connectorName = args.connector;
|
|
380
|
-
|
|
592
|
+
// Fold the owner to its short form so the singleton subject matches
|
|
593
|
+
// the periodic schedule's, and the workflow reads the credential and
|
|
594
|
+
// sync-state rows keyed on the normalized owner.
|
|
595
|
+
const ownerDID = normalizeDID(ctx.viewerDID);
|
|
381
596
|
const full = args.full ?? false;
|
|
382
597
|
// Defer to after the mutation commits — both the workflow enqueue
|
|
383
598
|
// (a store insert) and the fallback background run use the top-level
|
|
@@ -408,9 +623,20 @@ export function createConnectorPlugin(options) {
|
|
|
408
623
|
boundary: options.defaults?.boundary,
|
|
409
624
|
inFlight: orchestrateInFlight,
|
|
410
625
|
logger,
|
|
411
|
-
mutateDocuments: params.graph.mutateDocuments
|
|
626
|
+
mutateDocuments: params.graph.mutateDocuments,
|
|
627
|
+
emitAuthRequired: ({ provider, ownerDID: owner })=>emitAuthRequiredNow(provider, owner)
|
|
628
|
+
});
|
|
629
|
+
})().catch((err)=>{
|
|
630
|
+
// Surface a deferred-run failure instead of swallowing it — the
|
|
631
|
+
// workflow enqueue and the fallback orchestrate both settle here.
|
|
632
|
+
// The emit is failure-isolated (a throwing subscriber is logged
|
|
633
|
+
// by the emitter), so it needs no guard of its own.
|
|
634
|
+
manager.emitSyncEvent({
|
|
635
|
+
type: 'error',
|
|
636
|
+
connectorName,
|
|
637
|
+
error: err instanceof Error ? err.message : String(err)
|
|
412
638
|
});
|
|
413
|
-
})
|
|
639
|
+
});
|
|
414
640
|
});
|
|
415
641
|
return {
|
|
416
642
|
status: 'STARTED',
|
|
@@ -418,43 +644,30 @@ export function createConnectorPlugin(options) {
|
|
|
418
644
|
};
|
|
419
645
|
},
|
|
420
646
|
subscribeToSyncEvents: (connector)=>subscribeToConnectorSyncEvents(manager.syncEvents, connector),
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
647
|
+
// Viewer-scoped façade over the manager's INTERNAL merged lifecycle
|
|
648
|
+
// channel: the viewer captured here (folded to its short form, as
|
|
649
|
+
// every lifecycle payload's owner is) gates delivery, so one viewer
|
|
650
|
+
// never observes another's auth activity.
|
|
651
|
+
subscribeToLifecycleEvents: (connector)=>subscribeToConnectorLifecycleEvents(manager.lifecycleEvents, {
|
|
652
|
+
viewerDID: normalizeDID(ctx.viewerDID),
|
|
653
|
+
connector
|
|
654
|
+
}),
|
|
655
|
+
// Recurring-sync control. The schedule binds to the viewer as the
|
|
656
|
+
// credential owner; its subject is `${connector}:${normalizeDID(owner)}`
|
|
657
|
+
// — identical to the manual sync singletonKey, so scheduled and manual
|
|
424
658
|
// runs interlock. Scheduled fires later write with no viewer, via the
|
|
425
659
|
// engine-signed mutateDocuments path the workflow already uses.
|
|
426
|
-
enablePeriodicSync:
|
|
427
|
-
|
|
428
|
-
if (workflowAPI == null) {
|
|
429
|
-
throw new Error('workflow plugin required for periodic sync');
|
|
430
|
-
}
|
|
431
|
-
const ownerDID = ctx.viewerDID;
|
|
432
|
-
const subjectKey = `${connector}:${ownerDID}`;
|
|
433
|
-
const policy = policyOverride ?? options.defaults?.periodicSyncPolicy ?? DEFAULT_PERIODIC_SYNC_POLICY;
|
|
434
|
-
const { id } = await workflowAPI.scheduleAdaptive(CONNECTOR_SYNC_WORKFLOW, {
|
|
435
|
-
connectorName: connector,
|
|
436
|
-
ownerDID,
|
|
437
|
-
full: false
|
|
438
|
-
}, {
|
|
439
|
-
policy,
|
|
440
|
-
subjectKey
|
|
441
|
-
});
|
|
442
|
-
return workflowAPI.getPeriodicSync(id);
|
|
443
|
-
},
|
|
444
|
-
disablePeriodicSync: async (connector)=>{
|
|
445
|
-
const workflowAPI = await getWorkflowAPI();
|
|
446
|
-
if (workflowAPI == null) {
|
|
447
|
-
throw new Error('workflow plugin required for periodic sync');
|
|
448
|
-
}
|
|
449
|
-
const subjectKey = `${connector}:${ctx.viewerDID}`;
|
|
450
|
-
return workflowAPI.setPeriodicSyncEnabled(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`, false);
|
|
451
|
-
},
|
|
660
|
+
enablePeriodicSync: (connector, policyOverride)=>enablePeriodicSyncForOwner(connector, ctx.viewerDID, policyOverride),
|
|
661
|
+
disablePeriodicSync: (connector)=>disablePeriodicSyncForOwner(connector, ctx.viewerDID),
|
|
452
662
|
getPeriodicSync: async (connector)=>{
|
|
663
|
+
if (!registry.has(connector)) {
|
|
664
|
+
throw new Error(`unknown connector: ${connector}`);
|
|
665
|
+
}
|
|
453
666
|
const workflowAPI = await getWorkflowAPI();
|
|
454
667
|
if (workflowAPI == null) {
|
|
455
668
|
throw new Error('workflow plugin required for periodic sync');
|
|
456
669
|
}
|
|
457
|
-
const subjectKey =
|
|
670
|
+
const { subjectKey } = periodicSyncSubject(connector, ctx.viewerDID);
|
|
458
671
|
return workflowAPI.getPeriodicSync(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`);
|
|
459
672
|
},
|
|
460
673
|
executeAction: async (args, writeDocument)=>{
|
|
@@ -473,10 +686,18 @@ export function createConnectorPlugin(options) {
|
|
|
473
686
|
stores,
|
|
474
687
|
registry,
|
|
475
688
|
credentialProvider: requestProvider,
|
|
476
|
-
|
|
689
|
+
// The credential row is keyed on the normalized owner.
|
|
690
|
+
ownerDID: normalizeDID(ctx.viewerDID),
|
|
477
691
|
logger,
|
|
478
692
|
writeDocument,
|
|
479
|
-
writeAttachment
|
|
693
|
+
writeAttachment,
|
|
694
|
+
// The action runs inside the request's mutation transaction;
|
|
695
|
+
// defer the emit past commit so a subscriber that schedules a
|
|
696
|
+
// sync does not deadlock the single-connection tx still held
|
|
697
|
+
// here (same guard as completeAuth and onCredentialRemoved).
|
|
698
|
+
emitAuthRequired: ({ provider, ownerDID })=>{
|
|
699
|
+
stores.onCommit(()=>emitAuthRequiredNow(provider, ownerDID));
|
|
700
|
+
}
|
|
480
701
|
});
|
|
481
702
|
return {
|
|
482
703
|
documentID: result.documentID,
|
|
@@ -505,7 +726,23 @@ export function createConnectorPlugin(options) {
|
|
|
505
726
|
viewerDID: ctx.viewerDID,
|
|
506
727
|
stores,
|
|
507
728
|
credentialProvider: requestProvider,
|
|
508
|
-
hlc: params.hlc
|
|
729
|
+
hlc: params.hlc,
|
|
730
|
+
// Announce a removed credential to lifecycle consumers, carrying the
|
|
731
|
+
// provider's connectors and the owner in normalized (short) form.
|
|
732
|
+
// After the mutation commits, never inside it: a subscriber that
|
|
733
|
+
// schedules work writes through the top-level db, which would
|
|
734
|
+
// deadlock the single-connection tx still held here (same guard as
|
|
735
|
+
// completeAuth).
|
|
736
|
+
onCredentialRemoved: ({ provider, ownerDID })=>{
|
|
737
|
+
const connectors = registry.getAll().filter((connector)=>connector.auth.provider === provider).map((connector)=>connector.name);
|
|
738
|
+
stores.onCommit(()=>{
|
|
739
|
+
manager.emitLifecycle('connector:deauthenticated', {
|
|
740
|
+
provider,
|
|
741
|
+
connectors,
|
|
742
|
+
ownerDID: normalizeDID(ownerDID)
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
}
|
|
509
746
|
})
|
|
510
747
|
};
|
|
511
748
|
};
|
package/lib/manager.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ConnectorSyncEventPayload, SyncBoundary } from '@kubun/connector';
|
|
2
2
|
import type { Logger } from '@kubun/logger';
|
|
3
|
-
import { EventEmitter } from '@sozai/event';
|
|
3
|
+
import { EventEmitter, type EventsSource } from '@sozai/event';
|
|
4
4
|
import type { ConnectorRegistry } from './registry.js';
|
|
5
5
|
type SyncEvents = {
|
|
6
6
|
sync: ConnectorSyncEventPayload;
|
|
@@ -12,10 +12,63 @@ export type ConnectorActivatedEvent = {
|
|
|
12
12
|
export type ConnectorDeactivatedEvent = {
|
|
13
13
|
connectorName: string;
|
|
14
14
|
};
|
|
15
|
+
export type ConnectorAuthenticatedEvent = {
|
|
16
|
+
provider: string;
|
|
17
|
+
connectors: Array<string>;
|
|
18
|
+
ownerDID: string;
|
|
19
|
+
scopes: Array<string>;
|
|
20
|
+
};
|
|
21
|
+
export type ConnectorDeauthenticatedEvent = {
|
|
22
|
+
provider: string;
|
|
23
|
+
connectors: Array<string>;
|
|
24
|
+
ownerDID: string;
|
|
25
|
+
};
|
|
26
|
+
export type ConnectorCredentialRefreshedEvent = {
|
|
27
|
+
provider: string;
|
|
28
|
+
connectors: Array<string>;
|
|
29
|
+
ownerDID: string;
|
|
30
|
+
scopes: Array<string>;
|
|
31
|
+
expiresAt?: number;
|
|
32
|
+
};
|
|
33
|
+
export type ConnectorAuthRequiredEvent = {
|
|
34
|
+
provider: string;
|
|
35
|
+
connectors: Array<string>;
|
|
36
|
+
ownerDID: string;
|
|
37
|
+
reason: 'absent' | 'invalid_grant';
|
|
38
|
+
};
|
|
39
|
+
export type PeriodicSyncEnabledEvent = {
|
|
40
|
+
connector: string;
|
|
41
|
+
ownerDID: string;
|
|
42
|
+
subjectKey: string;
|
|
43
|
+
};
|
|
44
|
+
export type PeriodicSyncDisabledEvent = {
|
|
45
|
+
connector: string;
|
|
46
|
+
ownerDID: string;
|
|
47
|
+
subjectKey: string;
|
|
48
|
+
};
|
|
49
|
+
/** Typed lifecycle channel: one event name per producer, each with its own payload. */
|
|
50
|
+
export type ConnectorLifecycleEvents = {
|
|
51
|
+
'connector:authenticated': ConnectorAuthenticatedEvent;
|
|
52
|
+
'connector:deauthenticated': ConnectorDeauthenticatedEvent;
|
|
53
|
+
'connector:credentialRefreshed': ConnectorCredentialRefreshedEvent;
|
|
54
|
+
'connector:authRequired': ConnectorAuthRequiredEvent;
|
|
55
|
+
'periodicSync:enabled': PeriodicSyncEnabledEvent;
|
|
56
|
+
'periodicSync:disabled': PeriodicSyncDisabledEvent;
|
|
57
|
+
};
|
|
58
|
+
export type ConnectorLifecycleEventName = keyof ConnectorLifecycleEvents;
|
|
59
|
+
/** Merged lifecycle channel payload: each typed event tagged with its name. */
|
|
60
|
+
export type ConnectorLifecycleEventPayload = {
|
|
61
|
+
[Name in ConnectorLifecycleEventName]: {
|
|
62
|
+
type: Name;
|
|
63
|
+
} & ConnectorLifecycleEvents[Name];
|
|
64
|
+
}[ConnectorLifecycleEventName];
|
|
65
|
+
type LifecycleEvents = {
|
|
66
|
+
lifecycle: ConnectorLifecycleEventPayload;
|
|
67
|
+
};
|
|
15
68
|
export type ConnectorManagerEvents = {
|
|
16
69
|
'connector:activated': ConnectorActivatedEvent;
|
|
17
70
|
'connector:deactivated': ConnectorDeactivatedEvent;
|
|
18
|
-
};
|
|
71
|
+
} & ConnectorLifecycleEvents;
|
|
19
72
|
export type ConnectorManagerParams = {
|
|
20
73
|
registry: ConnectorRegistry;
|
|
21
74
|
logger: Logger;
|
|
@@ -36,8 +89,23 @@ export declare class ConnectorManager {
|
|
|
36
89
|
listActive(): Array<string>;
|
|
37
90
|
onConnectorActivated(callback: (event: ConnectorActivatedEvent) => void): () => void;
|
|
38
91
|
onConnectorDeactivated(callback: (event: ConnectorDeactivatedEvent) => void): () => void;
|
|
92
|
+
/**
|
|
93
|
+
* Listen-only view of the typed event channel. Consumers subscribe here (e.g.
|
|
94
|
+
* `events.on('connector:authenticated', …)`); the emit side stays private to
|
|
95
|
+
* the manager, so the returned surface carries no `fire`/`emit`.
|
|
96
|
+
*/
|
|
97
|
+
get events(): EventsSource<ConnectorManagerEvents>;
|
|
98
|
+
/** Listen-only view of the merged lifecycle channel carrying every tagged event. */
|
|
99
|
+
get lifecycleEvents(): EventsSource<LifecycleEvents>;
|
|
100
|
+
/**
|
|
101
|
+
* Emit a lifecycle event to both the typed per-event channel and the merged
|
|
102
|
+
* channel. The two `fire` calls are independent and failure-isolated: a
|
|
103
|
+
* throwing listener on either channel is reported via the logger and cannot
|
|
104
|
+
* reject the caller or block the other channel.
|
|
105
|
+
*/
|
|
106
|
+
emitLifecycle<Name extends ConnectorLifecycleEventName>(type: Name, payload: ConnectorLifecycleEvents[Name]): void;
|
|
39
107
|
get syncEvents(): EventEmitter<SyncEvents>;
|
|
40
|
-
emitSyncEvent(event: ConnectorSyncEventPayload):
|
|
108
|
+
emitSyncEvent(event: ConnectorSyncEventPayload): void;
|
|
41
109
|
onSyncEvent(callback: (event: ConnectorSyncEventPayload) => void): () => void;
|
|
42
110
|
}
|
|
43
111
|
export {};
|