@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/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 { DBCredentialProvider } from './credential.js';
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 mode
47
- // gets provenance as one more procedure, so both modes read it off one object.
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
- const workflowAPI = await getWorkflowAPI();
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: new DBCredentialProvider({
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(()=>undefined);
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: connectorAPI,
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)=>oauthService.completeAuth(args, requestProvider, requestAPI),
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
- const ownerDID = ctx.viewerDID;
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 emission itself rejects if a subscriber throws, so guard it:
417
- // an unhandled rejection here could crash a strict host.
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
- // 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
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: 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
- },
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 = `${connector}:${ctx.viewerDID}`;
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
- ownerDID: ctx.viewerDID,
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
  };