@kubun/plugin-connector 0.14.1 → 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 +284 -62
- package/lib/manager.d.ts +71 -3
- package/lib/manager.js +41 -6
- package/lib/oauth.d.ts +5 -1
- package/lib/oauth.js +5 -2
- package/lib/periodic-sync.d.ts +4 -0
- package/lib/periodic-sync.js +12 -0
- package/lib/schema.d.ts +45 -1
- package/lib/schema.js +122 -0
- package/lib/sync/orchestrate.d.ts +11 -1
- package/lib/sync/orchestrate.js +6 -0
- package/lib/sync/workflow.d.ts +11 -0
- package/lib/sync/workflow.js +26 -2
- package/lib/write-grants.d.ts +10 -2
- package/lib/write-grants.js +10 -1
- package/package.json +5 -5
package/lib/manager.js
CHANGED
|
@@ -3,12 +3,22 @@ export class ConnectorManager {
|
|
|
3
3
|
#registry;
|
|
4
4
|
#logger;
|
|
5
5
|
#activeGraphs = new Map();
|
|
6
|
-
#events
|
|
7
|
-
#syncEvents
|
|
6
|
+
#events;
|
|
7
|
+
#syncEvents;
|
|
8
|
+
#lifecycleEvents;
|
|
8
9
|
#defaults;
|
|
9
10
|
constructor(params){
|
|
10
11
|
this.#registry = params.registry;
|
|
11
12
|
this.#logger = params.logger;
|
|
13
|
+
this.#events = new EventEmitter({
|
|
14
|
+
logger: params.logger
|
|
15
|
+
});
|
|
16
|
+
this.#syncEvents = new EventEmitter({
|
|
17
|
+
logger: params.logger
|
|
18
|
+
});
|
|
19
|
+
this.#lifecycleEvents = new EventEmitter({
|
|
20
|
+
logger: params.logger
|
|
21
|
+
});
|
|
12
22
|
this.#defaults = {
|
|
13
23
|
boundary: params.defaults?.boundary ?? {
|
|
14
24
|
maxAge: 'P90D'
|
|
@@ -35,7 +45,7 @@ export class ConnectorManager {
|
|
|
35
45
|
name,
|
|
36
46
|
graphID
|
|
37
47
|
});
|
|
38
|
-
this.#events.
|
|
48
|
+
this.#events.fire('connector:activated', {
|
|
39
49
|
connectorName: name,
|
|
40
50
|
graphID
|
|
41
51
|
});
|
|
@@ -54,7 +64,7 @@ export class ConnectorManager {
|
|
|
54
64
|
this.#logger.info('Connector {name} deactivated', {
|
|
55
65
|
name
|
|
56
66
|
});
|
|
57
|
-
this.#events.
|
|
67
|
+
this.#events.fire('connector:deactivated', {
|
|
58
68
|
connectorName: name
|
|
59
69
|
});
|
|
60
70
|
}
|
|
@@ -77,11 +87,36 @@ export class ConnectorManager {
|
|
|
77
87
|
onConnectorDeactivated(callback) {
|
|
78
88
|
return this.#events.on('connector:deactivated', callback);
|
|
79
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Listen-only view of the typed event channel. Consumers subscribe here (e.g.
|
|
92
|
+
* `events.on('connector:authenticated', …)`); the emit side stays private to
|
|
93
|
+
* the manager, so the returned surface carries no `fire`/`emit`.
|
|
94
|
+
*/ get events() {
|
|
95
|
+
return this.#events;
|
|
96
|
+
}
|
|
97
|
+
/** Listen-only view of the merged lifecycle channel carrying every tagged event. */ get lifecycleEvents() {
|
|
98
|
+
return this.#lifecycleEvents;
|
|
99
|
+
}
|
|
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
|
+
*/ emitLifecycle(type, payload) {
|
|
106
|
+
this.#emitLifecycle(type, payload);
|
|
107
|
+
}
|
|
108
|
+
#emitLifecycle(type, payload) {
|
|
109
|
+
this.#events.fire(type, payload);
|
|
110
|
+
this.#lifecycleEvents.fire('lifecycle', {
|
|
111
|
+
type,
|
|
112
|
+
...payload
|
|
113
|
+
});
|
|
114
|
+
}
|
|
80
115
|
get syncEvents() {
|
|
81
116
|
return this.#syncEvents;
|
|
82
117
|
}
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
emitSyncEvent(event) {
|
|
119
|
+
this.#syncEvents.fire('sync', event);
|
|
85
120
|
}
|
|
86
121
|
onSyncEvent(callback) {
|
|
87
122
|
return this.#syncEvents.on('sync', callback);
|
package/lib/oauth.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ import type { Runtime } from '@sozai/runtime';
|
|
|
4
4
|
import type { ConnectorAPI, CredentialAPI } from './api.js';
|
|
5
5
|
import type { ConnectorRegistry } from './registry.js';
|
|
6
6
|
import type { CompleteConnectorAuthInput, CompleteConnectorAuthOutput, StartConnectorAuthInput, StartConnectorAuthOutput } from './schema.js';
|
|
7
|
+
export type CompleteAuthResult = {
|
|
8
|
+
output: CompleteConnectorAuthOutput;
|
|
9
|
+
ownerDID: string;
|
|
10
|
+
};
|
|
7
11
|
export type OAuthServiceParams = {
|
|
8
12
|
runtime: Runtime;
|
|
9
13
|
providers: Array<OAuthProviderDefinition>;
|
|
@@ -16,5 +20,5 @@ export declare class OAuthService {
|
|
|
16
20
|
#private;
|
|
17
21
|
constructor(params: OAuthServiceParams);
|
|
18
22
|
startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI, preflightMethods?: MethodRegistry): Promise<StartConnectorAuthOutput>;
|
|
19
|
-
completeAuth(args: CompleteConnectorAuthInput, credentialProvider?: CredentialProvider, connectorAPI?: ConnectorAPI): Promise<
|
|
23
|
+
completeAuth(args: CompleteConnectorAuthInput, credentialProvider?: CredentialProvider, connectorAPI?: ConnectorAPI): Promise<CompleteAuthResult>;
|
|
20
24
|
}
|
package/lib/oauth.js
CHANGED
|
@@ -167,8 +167,11 @@ export class OAuthService {
|
|
|
167
167
|
controllerWrappableDID: pending.controllerWrappableDID ?? undefined
|
|
168
168
|
});
|
|
169
169
|
return {
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
output: {
|
|
171
|
+
providerName: args.provider,
|
|
172
|
+
scopes
|
|
173
|
+
},
|
|
174
|
+
ownerDID: pending.ownerDID
|
|
172
175
|
};
|
|
173
176
|
}
|
|
174
177
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
// The adaptive schedule and the manual-sync singleton share one subject so a
|
|
3
|
+
// scheduled run and a manual trigger interlock. Both fold the owner to its short
|
|
4
|
+
// form first: credential rows and sync-state rows are keyed on the normalized
|
|
5
|
+
// DID, and a viewer can arrive long-form (a mutation's `iss`).
|
|
6
|
+
export function periodicSyncSubject(connector, ownerDID) {
|
|
7
|
+
const owner = normalizeDID(ownerDID);
|
|
8
|
+
return {
|
|
9
|
+
owner,
|
|
10
|
+
subjectKey: `${connector}:${owner}`
|
|
11
|
+
};
|
|
12
|
+
}
|
package/lib/schema.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ConnectorSyncEventPayload } from '@kubun/connector';
|
|
2
2
|
import type { SchemaExtension } from '@kubun/engine';
|
|
3
3
|
import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
|
|
4
|
-
import type { EventEmitter } from '@sozai/event';
|
|
4
|
+
import type { EventEmitter, EventsSource } from '@sozai/event';
|
|
5
|
+
import type { ConnectorLifecycleEventName, ConnectorLifecycleEventPayload } from './manager.js';
|
|
5
6
|
import type { ConnectorRegistry } from './registry.js';
|
|
6
7
|
import type { SignedDocumentWriter } from './sync/processor.js';
|
|
7
8
|
/**
|
|
@@ -19,6 +20,7 @@ export type ConnectorState = {
|
|
|
19
20
|
lastSyncedAt: string | null;
|
|
20
21
|
entityCount: number | null;
|
|
21
22
|
error: string | null;
|
|
23
|
+
workflowStatusError: 'WORKFLOW_UNAVAILABLE' | null;
|
|
22
24
|
};
|
|
23
25
|
/**
|
|
24
26
|
* Input/output types for connector auth mutations.
|
|
@@ -139,6 +141,47 @@ export type ConnectorSyncEventEmitter = EventEmitter<{
|
|
|
139
141
|
* potentially never.
|
|
140
142
|
*/
|
|
141
143
|
export declare function subscribeToConnectorSyncEvents(emitter: ConnectorSyncEventEmitter, connector?: string | null): AsyncIterableIterator<ConnectorSyncEventResult>;
|
|
144
|
+
/**
|
|
145
|
+
* Merged lifecycle event payload for subscriptions (GraphQL result type). Flat
|
|
146
|
+
* and nullable: a given event carries only the fields its variant defines, and
|
|
147
|
+
* the rest resolve to null (mirrors `ConnectorSyncEventResult`).
|
|
148
|
+
*/
|
|
149
|
+
export type ConnectorLifecycleEventResult = {
|
|
150
|
+
type: ConnectorLifecycleEventName;
|
|
151
|
+
ownerDID: string;
|
|
152
|
+
provider: string | null;
|
|
153
|
+
connector: string | null;
|
|
154
|
+
connectors: Array<string> | null;
|
|
155
|
+
scopes: Array<string> | null;
|
|
156
|
+
expiresAt: number | null;
|
|
157
|
+
reason: string | null;
|
|
158
|
+
subjectKey: string | null;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Flatten a merged lifecycle payload into the GraphQL result shape, filling the
|
|
162
|
+
* fields absent from the variant with null.
|
|
163
|
+
*/
|
|
164
|
+
export declare function toConnectorLifecycleEventResult(event: ConnectorLifecycleEventPayload): ConnectorLifecycleEventResult;
|
|
165
|
+
/**
|
|
166
|
+
* Subscribe to the merged lifecycle channel as a single viewer, yielding GraphQL
|
|
167
|
+
* result shapes. Every event is filtered by `ownerDID` against the caller's
|
|
168
|
+
* already-normalized `viewerDID`, so one viewer never observes another's auth
|
|
169
|
+
* activity; an optional `connector` further restricts delivery, matching both
|
|
170
|
+
* the auth-family `connectors[]` membership and the periodicSync single
|
|
171
|
+
* `connector`. Built on the same manual async iterator as
|
|
172
|
+
* `subscribeToConnectorSyncEvents` so a consumer's `return()` removes the
|
|
173
|
+
* emitter listener immediately rather than leaking behind a parked read.
|
|
174
|
+
*
|
|
175
|
+
* The emitter is the manager's listen-only `EventsSource`; `fromEmitter` only
|
|
176
|
+
* reads its `.on`, but its parameter is typed to the concrete emitter class, so
|
|
177
|
+
* the source is narrowed to it here.
|
|
178
|
+
*/
|
|
179
|
+
export declare function subscribeToConnectorLifecycleEvents(emitter: EventsSource<{
|
|
180
|
+
lifecycle: ConnectorLifecycleEventPayload;
|
|
181
|
+
}>, opts: {
|
|
182
|
+
viewerDID: string;
|
|
183
|
+
connector?: string | null;
|
|
184
|
+
}): AsyncIterableIterator<ConnectorLifecycleEventResult>;
|
|
142
185
|
/**
|
|
143
186
|
* Per-request context methods for the Connector query and mutation fields.
|
|
144
187
|
* Provided by the context factory when registry + credentials are configured.
|
|
@@ -153,6 +196,7 @@ export type ConnectorQueryContext = {
|
|
|
153
196
|
completeAuth: (args: CompleteConnectorAuthInput) => Promise<CompleteConnectorAuthOutput>;
|
|
154
197
|
triggerSync: (args: SyncTriggerInput) => Promise<SyncTriggerOutput>;
|
|
155
198
|
subscribeToSyncEvents: (connector?: string | null) => AsyncIterable<ConnectorSyncEventResult>;
|
|
199
|
+
subscribeToLifecycleEvents: (connector?: string | null) => AsyncIterable<ConnectorLifecycleEventResult>;
|
|
156
200
|
executeAction?: (args: ConnectorActionInput, writeDocument: SignedDocumentWriter) => Promise<ConnectorActionResult>;
|
|
157
201
|
grantWriteCapability?: (args: {
|
|
158
202
|
connector: string;
|
package/lib/schema.js
CHANGED
|
@@ -58,6 +58,107 @@ import { fromEmitter } from '@sozai/generator';
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Flatten a merged lifecycle payload into the GraphQL result shape, filling the
|
|
63
|
+
* fields absent from the variant with null.
|
|
64
|
+
*/ export function toConnectorLifecycleEventResult(event) {
|
|
65
|
+
const result = {
|
|
66
|
+
type: event.type,
|
|
67
|
+
ownerDID: event.ownerDID,
|
|
68
|
+
provider: null,
|
|
69
|
+
connector: null,
|
|
70
|
+
connectors: null,
|
|
71
|
+
scopes: null,
|
|
72
|
+
expiresAt: null,
|
|
73
|
+
reason: null,
|
|
74
|
+
subjectKey: null
|
|
75
|
+
};
|
|
76
|
+
switch(event.type){
|
|
77
|
+
case 'connector:authenticated':
|
|
78
|
+
result.provider = event.provider;
|
|
79
|
+
result.connectors = event.connectors;
|
|
80
|
+
result.scopes = event.scopes;
|
|
81
|
+
break;
|
|
82
|
+
case 'connector:deauthenticated':
|
|
83
|
+
result.provider = event.provider;
|
|
84
|
+
result.connectors = event.connectors;
|
|
85
|
+
break;
|
|
86
|
+
case 'connector:credentialRefreshed':
|
|
87
|
+
result.provider = event.provider;
|
|
88
|
+
result.connectors = event.connectors;
|
|
89
|
+
result.scopes = event.scopes;
|
|
90
|
+
result.expiresAt = event.expiresAt ?? null;
|
|
91
|
+
break;
|
|
92
|
+
case 'connector:authRequired':
|
|
93
|
+
result.provider = event.provider;
|
|
94
|
+
result.connectors = event.connectors;
|
|
95
|
+
result.reason = event.reason;
|
|
96
|
+
break;
|
|
97
|
+
case 'periodicSync:enabled':
|
|
98
|
+
case 'periodicSync:disabled':
|
|
99
|
+
result.connector = event.connector;
|
|
100
|
+
result.subjectKey = event.subjectKey;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Subscribe to the merged lifecycle channel as a single viewer, yielding GraphQL
|
|
107
|
+
* result shapes. Every event is filtered by `ownerDID` against the caller's
|
|
108
|
+
* already-normalized `viewerDID`, so one viewer never observes another's auth
|
|
109
|
+
* activity; an optional `connector` further restricts delivery, matching both
|
|
110
|
+
* the auth-family `connectors[]` membership and the periodicSync single
|
|
111
|
+
* `connector`. Built on the same manual async iterator as
|
|
112
|
+
* `subscribeToConnectorSyncEvents` so a consumer's `return()` removes the
|
|
113
|
+
* emitter listener immediately rather than leaking behind a parked read.
|
|
114
|
+
*
|
|
115
|
+
* The emitter is the manager's listen-only `EventsSource`; `fromEmitter` only
|
|
116
|
+
* reads its `.on`, but its parameter is typed to the concrete emitter class, so
|
|
117
|
+
* the source is narrowed to it here.
|
|
118
|
+
*/ export function subscribeToConnectorLifecycleEvents(emitter, opts) {
|
|
119
|
+
const { viewerDID, connector } = opts;
|
|
120
|
+
const filter = (event)=>{
|
|
121
|
+
if (event.ownerDID !== viewerDID) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (connector == null) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return 'connectors' in event ? event.connectors.includes(connector) : event.connector === connector;
|
|
128
|
+
};
|
|
129
|
+
const generator = fromEmitter(emitter, 'lifecycle', {
|
|
130
|
+
filter
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
[Symbol.asyncIterator] () {
|
|
134
|
+
return this;
|
|
135
|
+
},
|
|
136
|
+
async next () {
|
|
137
|
+
const { done, value } = await generator.next();
|
|
138
|
+
return done ? {
|
|
139
|
+
done: true,
|
|
140
|
+
value: undefined
|
|
141
|
+
} : {
|
|
142
|
+
done: false,
|
|
143
|
+
value: toConnectorLifecycleEventResult(value)
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
async return () {
|
|
147
|
+
await generator.return();
|
|
148
|
+
return {
|
|
149
|
+
done: true,
|
|
150
|
+
value: undefined
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
async throw (reason) {
|
|
154
|
+
await generator.throw(reason);
|
|
155
|
+
return {
|
|
156
|
+
done: true,
|
|
157
|
+
value: undefined
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
61
162
|
function requireConnector(ctx) {
|
|
62
163
|
if (ctx.connector == null) {
|
|
63
164
|
throw new Error('connector plugin is not wired into this context');
|
|
@@ -116,6 +217,7 @@ type Connector implements Node {
|
|
|
116
217
|
lastSyncedAt: String
|
|
117
218
|
entityCount: Int
|
|
118
219
|
error: String
|
|
220
|
+
workflowStatusError: String
|
|
119
221
|
}
|
|
120
222
|
|
|
121
223
|
type StartConnectorAuthResult {
|
|
@@ -144,6 +246,18 @@ type ConnectorSyncEvent {
|
|
|
144
246
|
error: String
|
|
145
247
|
}
|
|
146
248
|
|
|
249
|
+
type ConnectorLifecycleEvent {
|
|
250
|
+
type: String!
|
|
251
|
+
ownerDID: String!
|
|
252
|
+
provider: String
|
|
253
|
+
connector: String
|
|
254
|
+
connectors: [String!]
|
|
255
|
+
scopes: [String!]
|
|
256
|
+
expiresAt: Float
|
|
257
|
+
reason: String
|
|
258
|
+
subjectKey: String
|
|
259
|
+
}
|
|
260
|
+
|
|
147
261
|
type ConnectorActionError {
|
|
148
262
|
code: String!
|
|
149
263
|
message: String!
|
|
@@ -175,6 +289,7 @@ extend type Mutation {
|
|
|
175
289
|
|
|
176
290
|
extend type Subscription {
|
|
177
291
|
connectorSyncEvents(connector: String): ConnectorSyncEvent!
|
|
292
|
+
connectorLifecycleEvents(connector: String): ConnectorLifecycleEvent!
|
|
178
293
|
}
|
|
179
294
|
`);
|
|
180
295
|
// Recurring-sync control. Gated because it references `PeriodicSync`, which is
|
|
@@ -398,6 +513,13 @@ input ConnectorUpdate${modelName}Input {
|
|
|
398
513
|
const ctx = context;
|
|
399
514
|
return requireConnector(ctx).subscribeToSyncEvents(args.connector);
|
|
400
515
|
}
|
|
516
|
+
},
|
|
517
|
+
connectorLifecycleEvents: {
|
|
518
|
+
resolve: (event)=>event,
|
|
519
|
+
subscribe: (_source, args, context)=>{
|
|
520
|
+
const ctx = context;
|
|
521
|
+
return requireConnector(ctx).subscribeToLifecycleEvents(args.connector);
|
|
522
|
+
}
|
|
401
523
|
}
|
|
402
524
|
};
|
|
403
525
|
return {
|
|
@@ -10,7 +10,7 @@ import type { MutateDocuments } from './processor.js';
|
|
|
10
10
|
* this type can be replaced with the concrete class.
|
|
11
11
|
*/
|
|
12
12
|
export type SyncEventEmitter = {
|
|
13
|
-
emitSyncEvent(event: ConnectorSyncEventPayload):
|
|
13
|
+
emitSyncEvent(event: ConnectorSyncEventPayload): void;
|
|
14
14
|
};
|
|
15
15
|
export type OrchestrateSyncParams = {
|
|
16
16
|
connectorName: string;
|
|
@@ -33,6 +33,16 @@ export type OrchestrateSyncParams = {
|
|
|
33
33
|
* imports upsert by unique key).
|
|
34
34
|
*/
|
|
35
35
|
inFlight?: Set<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Signal that this sync bailed because the provider credential is absent, so a
|
|
38
|
+
* consumer can prompt (re)authorization. Additive to the sync `error` event —
|
|
39
|
+
* not a replacement. Fired only for a missing credential, never for an absent
|
|
40
|
+
* server provider.
|
|
41
|
+
*/
|
|
42
|
+
emitAuthRequired?: (params: {
|
|
43
|
+
provider: string;
|
|
44
|
+
ownerDID: string;
|
|
45
|
+
}) => void;
|
|
36
46
|
};
|
|
37
47
|
export type OrchestrateSyncResult = {
|
|
38
48
|
status: 'started' | 'already_syncing' | 'error';
|
package/lib/sync/orchestrate.js
CHANGED
|
@@ -56,6 +56,12 @@ export async function orchestrateSync(params) {
|
|
|
56
56
|
const credential = providerName ? await credentialProvider.get(providerName, ownerDID) : null;
|
|
57
57
|
if (providerName != null && credential == null) {
|
|
58
58
|
release();
|
|
59
|
+
// Additive to the error event below: the sync stream still sees the failure,
|
|
60
|
+
// and a lifecycle consumer additionally learns (re)authorization is needed.
|
|
61
|
+
params.emitAuthRequired?.({
|
|
62
|
+
provider: providerName,
|
|
63
|
+
ownerDID
|
|
64
|
+
});
|
|
59
65
|
await emitError(`no credential for provider "${providerName}"`);
|
|
60
66
|
return {
|
|
61
67
|
status: 'error'
|
package/lib/sync/workflow.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export type ConnectorSyncWorkflowDefinition = {
|
|
|
33
33
|
name: string;
|
|
34
34
|
};
|
|
35
35
|
handlers: Record<string, ConnectorSyncHandler>;
|
|
36
|
+
managed: true;
|
|
36
37
|
};
|
|
37
38
|
export type ConnectorSyncWorkflowParams = {
|
|
38
39
|
registry: ConnectorRegistry;
|
|
@@ -43,6 +44,16 @@ export type ConnectorSyncWorkflowParams = {
|
|
|
43
44
|
mutateDocuments?: MutateDocuments;
|
|
44
45
|
boundary?: SyncBoundary;
|
|
45
46
|
logger: Logger;
|
|
47
|
+
/**
|
|
48
|
+
* Signal that a sync cannot proceed because the provider credential is absent,
|
|
49
|
+
* so a consumer can prompt (re)authorization. Fired only for a genuinely
|
|
50
|
+
* missing credential — never for an absent server provider, which is an
|
|
51
|
+
* ordinary offline/configuration outcome.
|
|
52
|
+
*/
|
|
53
|
+
emitAuthRequired?: (params: {
|
|
54
|
+
provider: string;
|
|
55
|
+
ownerDID: string;
|
|
56
|
+
}) => void;
|
|
46
57
|
};
|
|
47
58
|
/**
|
|
48
59
|
* Build the `connector-sync` workflow definition. One sync pass is a self-looping
|
package/lib/sync/workflow.js
CHANGED
|
@@ -14,7 +14,7 @@ import { MAX_LOGGED_ERRORS } from './processor.js';
|
|
|
14
14
|
* definitions (two "boots" over one DB) do not share iterators — which is what
|
|
15
15
|
* makes crash-recovery re-open the provider from the checkpoint.
|
|
16
16
|
*/ export function createConnectorSyncWorkflow(params) {
|
|
17
|
-
const { registry, credentialProvider, stateStore, syncEventEmitter, stores, mutateDocuments, logger } = params;
|
|
17
|
+
const { registry, credentialProvider, stateStore, syncEventEmitter, stores, mutateDocuments, logger, emitAuthRequired } = params;
|
|
18
18
|
const sessions = new Map();
|
|
19
19
|
const sessionKey = (state)=>`${state.connectorName}:${state.ownerDID}`;
|
|
20
20
|
const effectiveBoundary = params.boundary ?? {
|
|
@@ -64,6 +64,15 @@ import { MAX_LOGGED_ERRORS } from './processor.js';
|
|
|
64
64
|
lastSyncedAt: new Date().toISOString(),
|
|
65
65
|
failedSample: []
|
|
66
66
|
};
|
|
67
|
+
// Only a genuinely absent credential asks for (re)authorization; an absent
|
|
68
|
+
// server provider stays a silent configuration outcome. Both still end
|
|
69
|
+
// offline (the event is additive, not a change to the offline outcome).
|
|
70
|
+
if (providerName != null && credential == null) {
|
|
71
|
+
emitAuthRequired?.({
|
|
72
|
+
provider: providerName,
|
|
73
|
+
ownerDID
|
|
74
|
+
});
|
|
75
|
+
}
|
|
67
76
|
return {
|
|
68
77
|
status: 'end',
|
|
69
78
|
state: offlineState,
|
|
@@ -126,6 +135,20 @@ import { MAX_LOGGED_ERRORS } from './processor.js';
|
|
|
126
135
|
}
|
|
127
136
|
const providerName = connector.auth.provider !== 'device' ? connector.auth.provider : null;
|
|
128
137
|
const credential = providerName ? await credentialProvider.get(providerName, state.ownerDID) : null;
|
|
138
|
+
// A crash-recovered batch whose credential has since been removed cannot
|
|
139
|
+
// resume: signal (re)authorization and end offline rather than opening the
|
|
140
|
+
// provider with an empty credential (which would fail mid-sync ⇒ error).
|
|
141
|
+
if (providerName != null && credential == null) {
|
|
142
|
+
emitAuthRequired?.({
|
|
143
|
+
provider: providerName,
|
|
144
|
+
ownerDID: state.ownerDID
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
status: 'end',
|
|
148
|
+
state,
|
|
149
|
+
outcome: 'offline'
|
|
150
|
+
};
|
|
151
|
+
}
|
|
129
152
|
if (connector.serverProvider == null) {
|
|
130
153
|
throw new Error(`Connector "${state.connectorName}" has no server provider`);
|
|
131
154
|
}
|
|
@@ -258,7 +281,8 @@ import { MAX_LOGGED_ERRORS } from './processor.js';
|
|
|
258
281
|
start,
|
|
259
282
|
batch,
|
|
260
283
|
finalize
|
|
261
|
-
}
|
|
284
|
+
},
|
|
285
|
+
managed: true
|
|
262
286
|
}
|
|
263
287
|
};
|
|
264
288
|
}
|
package/lib/write-grants.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import type { CredentialProvider } from '@kubun/credential-types';
|
|
1
|
+
import type { Credential, CredentialProvider } from '@kubun/credential-types';
|
|
2
2
|
import { HLC } from '@kubun/hlc';
|
|
3
3
|
import { getDelegationStore } from '@kubun/store-delegation';
|
|
4
4
|
import type { ConnectorRegistry } from './registry.js';
|
|
5
5
|
import type { ConnectorWriteGrant } from './schema.js';
|
|
6
|
+
type CredentialFacade = CredentialProvider & {
|
|
7
|
+
peek?(providerName: string, ownerDID: string): Promise<Credential | null>;
|
|
8
|
+
};
|
|
6
9
|
export type WriteGrantContextParams = {
|
|
7
10
|
registry: ConnectorRegistry;
|
|
8
11
|
/** The server identity a viewer delegates its write authority to (`aud`). */
|
|
@@ -10,8 +13,12 @@ export type WriteGrantContextParams = {
|
|
|
10
13
|
/** The calling viewer (raw, unnormalized) — the grantor of every cap. */
|
|
11
14
|
viewerDID: string;
|
|
12
15
|
stores: Parameters<typeof getDelegationStore>[0];
|
|
13
|
-
credentialProvider:
|
|
16
|
+
credentialProvider: CredentialFacade;
|
|
14
17
|
hlc: HLC;
|
|
18
|
+
onCredentialRemoved?: (params: {
|
|
19
|
+
provider: string;
|
|
20
|
+
ownerDID: string;
|
|
21
|
+
}) => void;
|
|
15
22
|
};
|
|
16
23
|
export type WriteGrantContext = {
|
|
17
24
|
grantWriteCapability(args: {
|
|
@@ -33,3 +40,4 @@ export type WriteGrantContext = {
|
|
|
33
40
|
* stored `resource` string handled here is only the revoke/list selector.
|
|
34
41
|
*/
|
|
35
42
|
export declare function createWriteGrantContext(params: WriteGrantContextParams): WriteGrantContext;
|
|
43
|
+
export {};
|
package/lib/write-grants.js
CHANGED
|
@@ -240,8 +240,17 @@ async function revokeCapsSubsetOf(delegationStore, grantor, audience, allowed) {
|
|
|
240
240
|
}
|
|
241
241
|
const delegationStore = await getDelegationStore(stores);
|
|
242
242
|
const removed = await revokeCapsSubsetOf(delegationStore, viewerDID, audienceDID, providerURNs);
|
|
243
|
-
|
|
243
|
+
// Test existence with the non-refreshing peek (falling back to `get` only
|
|
244
|
+
// when a caller supplies no peek) so disconnecting an expiring credential
|
|
245
|
+
// does not trigger a transparent refresh right before the delete.
|
|
246
|
+
const credentialExisted = (credentialProvider.peek != null ? await credentialProvider.peek(provider, viewerDID) : await credentialProvider.get(provider, viewerDID)) != null;
|
|
244
247
|
await credentialProvider.delete(provider, viewerDID);
|
|
248
|
+
if (credentialExisted) {
|
|
249
|
+
params.onCredentialRemoved?.({
|
|
250
|
+
provider,
|
|
251
|
+
ownerDID: viewerDID
|
|
252
|
+
});
|
|
253
|
+
}
|
|
245
254
|
return removed > 0 || credentialExisted;
|
|
246
255
|
},
|
|
247
256
|
connectorWriteGrants: async ()=>{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/plugin-connector",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"@kokuin/jwe": "^0.1.0",
|
|
19
19
|
"@kokuin/token": "^0.5.0",
|
|
20
20
|
"@kubun/connector": "^0.14.0",
|
|
21
|
-
"@kubun/credential": "^0.14.
|
|
21
|
+
"@kubun/credential": "^0.14.2",
|
|
22
22
|
"@kubun/credential-types": "^0.14.0",
|
|
23
23
|
"@kubun/db": "^0.14.0",
|
|
24
24
|
"@kubun/engine": "^0.14.1",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@kubun/id": "^0.14.0",
|
|
28
28
|
"@kubun/logger": "^0.14.0",
|
|
29
29
|
"@kubun/plugin-blob-api": "^0.14.0",
|
|
30
|
-
"@kubun/plugin-workflow-api": "^0.14.
|
|
30
|
+
"@kubun/plugin-workflow-api": "^0.14.1",
|
|
31
31
|
"@kubun/protocol": "^0.14.1",
|
|
32
32
|
"@kubun/service-credential-api": "^0.14.0",
|
|
33
33
|
"@kubun/store-connector": "^0.14.0",
|
|
@@ -56,11 +56,11 @@
|
|
|
56
56
|
"@kubun/plugin-credential": "^0.14.0",
|
|
57
57
|
"@kubun/plugin-service-client": "^0.14.0",
|
|
58
58
|
"@kubun/plugin-service-server": "^0.14.0",
|
|
59
|
-
"@kubun/plugin-workflow": "^0.14.
|
|
59
|
+
"@kubun/plugin-workflow": "^0.14.1",
|
|
60
60
|
"@kubun/service-controller-log-api": "^0.14.0",
|
|
61
61
|
"@kubun/store-blob": "^0.14.0",
|
|
62
62
|
"@kubun/store-controller": "^0.14.0",
|
|
63
|
-
"@kubun/store-workflow": "^0.14.
|
|
63
|
+
"@kubun/store-workflow": "^0.14.1",
|
|
64
64
|
"@kubun/test-utils": "^0.13.0",
|
|
65
65
|
"@testcontainers/postgresql": "^12.1.0",
|
|
66
66
|
"get-port": "^7.2.0"
|