@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/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,132 @@ 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
+ // 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: connectorAPI,
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)=>oauthService.completeAuth(args, requestProvider, requestAPI),
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
- const ownerDID = ctx.viewerDID;
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
- })().catch(()=>{});
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
- // Recurring-sync control. Viewer-at-enable: the schedule is registered
422
- // under a viewer and its subject is `${connector}:${ownerDID}` —
423
- // identical to the manual sync singletonKey, so scheduled and manual
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: async (connector, policyOverride)=>{
427
- const workflowAPI = await getWorkflowAPI();
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 = `${connector}:${ctx.viewerDID}`;
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
- ownerDID: ctx.viewerDID,
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): Promise<void>;
108
+ emitSyncEvent(event: ConnectorSyncEventPayload): void;
41
109
  onSyncEvent(callback: (event: ConnectorSyncEventPayload) => void): () => void;
42
110
  }
43
111
  export {};