@kubun/plugin-connector 0.9.0 → 0.11.0
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 +10 -0
- package/lib/action.js +106 -1
- package/lib/api.d.ts +9 -0
- package/lib/api.js +108 -1
- package/lib/boundary.js +73 -1
- package/lib/credential.d.ts +1 -1
- package/lib/credential.js +107 -1
- package/lib/index.d.ts +3 -1
- package/lib/index.js +223 -1
- package/lib/manager.d.ts +1 -1
- package/lib/manager.js +90 -1
- package/lib/oauth.d.ts +5 -3
- package/lib/oauth.js +137 -1
- package/lib/registry.js +24 -1
- package/lib/schema.d.ts +44 -2
- package/lib/schema.js +300 -13
- package/lib/sync/engine.d.ts +6 -0
- package/lib/sync/engine.js +163 -1
- package/lib/sync/orchestrate.d.ts +6 -0
- package/lib/sync/orchestrate.js +100 -1
- package/lib/sync/processor.d.ts +34 -2
- package/lib/sync/processor.js +191 -1
- package/lib/sync/state.d.ts +2 -0
- package/lib/sync/state.js +57 -1
- package/lib/write-grants.d.ts +35 -0
- package/lib/write-grants.js +286 -0
- package/package.json +26 -20
package/lib/index.js
CHANGED
|
@@ -1 +1,223 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import { connectorStoreDefinition } from '@kubun/store-connector';
|
|
2
|
+
import { executeAction } from './action.js';
|
|
3
|
+
import { createConnectorAPI } from './api.js';
|
|
4
|
+
import { DBCredentialProvider } from './credential.js';
|
|
5
|
+
import { ConnectorManager } from './manager.js';
|
|
6
|
+
import { OAuthService } from './oauth.js';
|
|
7
|
+
import { ConnectorRegistry } from './registry.js';
|
|
8
|
+
import { createConnectorSchemaExtension, subscribeToConnectorSyncEvents } from './schema.js';
|
|
9
|
+
import { orchestrateSync } from './sync/orchestrate.js';
|
|
10
|
+
import { DBSyncStateStore } from './sync/state.js';
|
|
11
|
+
import { createWriteGrantContext } from './write-grants.js';
|
|
12
|
+
// ---- Re-exports ----
|
|
13
|
+
export { executeAction } from './action.js';
|
|
14
|
+
export { ConnectorManager } from './manager.js';
|
|
15
|
+
export { OAuthService } from './oauth.js';
|
|
16
|
+
export { ConnectorRegistry } from './registry.js';
|
|
17
|
+
export { createConnectorSchemaExtension, toConnectorSyncEventResult } from './schema.js';
|
|
18
|
+
export { SyncEngine } from './sync/engine.js';
|
|
19
|
+
export { orchestrateSync } from './sync/orchestrate.js';
|
|
20
|
+
export { EntityProcessor } from './sync/processor.js';
|
|
21
|
+
export { DBSyncStateStore } from './sync/state.js';
|
|
22
|
+
export { DBCredentialProvider };
|
|
23
|
+
// ---- Plugin factory options ----
|
|
24
|
+
/** Default sync-lease TTL (5 minutes) — an in-flight sync heartbeats this before expiry. */ const DEFAULT_LEASE_TTL_MS = 300_000;
|
|
25
|
+
// ---- Connector state resolution ----
|
|
26
|
+
async function resolveConnectorState(connectorAPI, registry, credentialProvider, viewerDID, connectorName) {
|
|
27
|
+
const connector = registry.get(connectorName);
|
|
28
|
+
if (connector == null) {
|
|
29
|
+
throw new Error(`Connector "${connectorName}" not found`);
|
|
30
|
+
}
|
|
31
|
+
const syncState = await connectorAPI.getSyncState(connectorName, viewerDID);
|
|
32
|
+
let authenticated = false;
|
|
33
|
+
let hasWriteAccess = false;
|
|
34
|
+
let authExpiresAt = null;
|
|
35
|
+
if (connector.auth.provider !== 'device') {
|
|
36
|
+
const credential = await credentialProvider.get(connector.auth.provider, viewerDID);
|
|
37
|
+
if (credential != null) {
|
|
38
|
+
authenticated = true;
|
|
39
|
+
if (credential.expiresAt != null) {
|
|
40
|
+
authExpiresAt = credential.expiresAt.toISOString();
|
|
41
|
+
}
|
|
42
|
+
const auth = connector.auth;
|
|
43
|
+
if (auth.writeScopes == null) {
|
|
44
|
+
hasWriteAccess = true;
|
|
45
|
+
} else {
|
|
46
|
+
hasWriteAccess = auth.writeScopes.every((scope)=>credential.scopes.includes(scope));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (syncState == null) {
|
|
51
|
+
return {
|
|
52
|
+
name: connectorName,
|
|
53
|
+
status: 'IDLE',
|
|
54
|
+
authenticated,
|
|
55
|
+
hasWriteAccess,
|
|
56
|
+
authExpiresAt,
|
|
57
|
+
lastSyncedAt: null,
|
|
58
|
+
entityCount: null,
|
|
59
|
+
error: null
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
name: connectorName,
|
|
64
|
+
status: syncState.status === 'idle' ? 'IDLE' : syncState.status === 'syncing' ? 'SYNCING' : 'ERROR',
|
|
65
|
+
authenticated,
|
|
66
|
+
hasWriteAccess,
|
|
67
|
+
authExpiresAt,
|
|
68
|
+
lastSyncedAt: syncState.lastSyncedAt ?? null,
|
|
69
|
+
entityCount: syncState.entityCount ?? null,
|
|
70
|
+
error: syncState.error ?? null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// ---- Plugin factory ----
|
|
74
|
+
export function createConnectorPlugin(options) {
|
|
75
|
+
const providers = options.providers ?? [];
|
|
76
|
+
const leaseMs = options.syncLeaseTTL ?? DEFAULT_LEASE_TTL_MS;
|
|
77
|
+
return (params)=>{
|
|
78
|
+
params.db.register(connectorStoreDefinition);
|
|
79
|
+
const logger = params.getLogger('connector');
|
|
80
|
+
// On a multi-tenant server the connector signs writes with the server
|
|
81
|
+
// identity, but imported documents must be owned by the account viewer. A
|
|
82
|
+
// viewer delegates its own write authority to this server DID, so accepted
|
|
83
|
+
// grants must be addressed to it (`aud`).
|
|
84
|
+
const serverDID = params.identity.id;
|
|
85
|
+
const registry = new ConnectorRegistry();
|
|
86
|
+
for (const connector of options.connectors){
|
|
87
|
+
registry.register(connector);
|
|
88
|
+
}
|
|
89
|
+
const connectorAPI = createConnectorAPI({
|
|
90
|
+
stores: params.db,
|
|
91
|
+
cipher: params.cipher
|
|
92
|
+
});
|
|
93
|
+
const credentialProvider = new DBCredentialProvider({
|
|
94
|
+
api: connectorAPI,
|
|
95
|
+
runtime: params.runtime,
|
|
96
|
+
providers
|
|
97
|
+
});
|
|
98
|
+
const stateStore = new DBSyncStateStore({
|
|
99
|
+
api: connectorAPI
|
|
100
|
+
});
|
|
101
|
+
const manager = new ConnectorManager({
|
|
102
|
+
registry,
|
|
103
|
+
logger,
|
|
104
|
+
defaults: options.defaults
|
|
105
|
+
});
|
|
106
|
+
const oauthService = new OAuthService({
|
|
107
|
+
runtime: params.runtime,
|
|
108
|
+
providers,
|
|
109
|
+
registry,
|
|
110
|
+
credentialProvider,
|
|
111
|
+
connectorAPI
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
name: 'connector',
|
|
115
|
+
schemaExtension: (config)=>createConnectorSchemaExtension({
|
|
116
|
+
registry,
|
|
117
|
+
config: config
|
|
118
|
+
}),
|
|
119
|
+
api: connectorAPI,
|
|
120
|
+
createContextFactory: ()=>{
|
|
121
|
+
return (ctx, stores)=>{
|
|
122
|
+
// Per-request connector API and credential provider backed by the
|
|
123
|
+
// request's StoreProvider (transactional during mutations).
|
|
124
|
+
const requestAPI = createConnectorAPI({
|
|
125
|
+
stores,
|
|
126
|
+
cipher: params.cipher
|
|
127
|
+
});
|
|
128
|
+
const requestCredentialProvider = new DBCredentialProvider({
|
|
129
|
+
api: requestAPI,
|
|
130
|
+
runtime: params.runtime,
|
|
131
|
+
providers
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
getState: (name)=>resolveConnectorState(requestAPI, registry, requestCredentialProvider, ctx.viewerDID, name),
|
|
135
|
+
getStates: ()=>{
|
|
136
|
+
const names = registry.list();
|
|
137
|
+
return Promise.all(names.map((name)=>resolveConnectorState(requestAPI, registry, requestCredentialProvider, ctx.viewerDID, name)));
|
|
138
|
+
},
|
|
139
|
+
startAuth: (args)=>oauthService.startAuth(args, ctx.viewerDID, requestAPI),
|
|
140
|
+
completeAuth: (args)=>oauthService.completeAuth(args, requestCredentialProvider, requestAPI),
|
|
141
|
+
triggerSync: async (args)=>{
|
|
142
|
+
if (!registry.has(args.connector)) {
|
|
143
|
+
return {
|
|
144
|
+
status: 'ERROR',
|
|
145
|
+
connectorName: args.connector
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Defer sync to after transaction commits — orchestrateSync starts
|
|
149
|
+
// fire-and-forget background work that uses KubunDB.getStore() directly,
|
|
150
|
+
// which would deadlock if the mutation transaction still holds the connection.
|
|
151
|
+
stores.onCommit(()=>{
|
|
152
|
+
orchestrateSync({
|
|
153
|
+
connectorName: args.connector,
|
|
154
|
+
full: args.full ?? false,
|
|
155
|
+
registry,
|
|
156
|
+
syncEventEmitter: manager,
|
|
157
|
+
stateStore,
|
|
158
|
+
credentialProvider,
|
|
159
|
+
ownerDID: ctx.viewerDID,
|
|
160
|
+
stores: params.db,
|
|
161
|
+
boundary: options.defaults?.boundary,
|
|
162
|
+
leaseMs,
|
|
163
|
+
logger,
|
|
164
|
+
mutateDocuments: params.graph.mutateDocuments
|
|
165
|
+
}).catch(()=>{});
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
status: 'STARTED',
|
|
169
|
+
connectorName: args.connector
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
subscribeToSyncEvents: (connector)=>subscribeToConnectorSyncEvents(manager.syncEvents, connector),
|
|
173
|
+
executeAction: async (args, writeDocument)=>{
|
|
174
|
+
try {
|
|
175
|
+
const result = await executeAction({
|
|
176
|
+
connector: args.connector,
|
|
177
|
+
action: args.action,
|
|
178
|
+
model: args.model,
|
|
179
|
+
input: args.input,
|
|
180
|
+
sourceID: args.sourceID
|
|
181
|
+
}, {
|
|
182
|
+
stores,
|
|
183
|
+
registry,
|
|
184
|
+
credentialProvider: requestCredentialProvider,
|
|
185
|
+
ownerDID: ctx.viewerDID,
|
|
186
|
+
logger,
|
|
187
|
+
writeDocument
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
documentID: result.documentID,
|
|
191
|
+
entity: result.entity,
|
|
192
|
+
error: null
|
|
193
|
+
};
|
|
194
|
+
} catch (err) {
|
|
195
|
+
const errorObj = err;
|
|
196
|
+
const code = errorObj.code ?? 'API_ERROR';
|
|
197
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
198
|
+
const requiredScopes = errorObj.requiredScopes;
|
|
199
|
+
return {
|
|
200
|
+
documentID: null,
|
|
201
|
+
entity: null,
|
|
202
|
+
error: {
|
|
203
|
+
code,
|
|
204
|
+
message,
|
|
205
|
+
requiredScopes
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
...createWriteGrantContext({
|
|
211
|
+
registry,
|
|
212
|
+
serverDID,
|
|
213
|
+
viewerDID: ctx.viewerDID,
|
|
214
|
+
stores,
|
|
215
|
+
credentialProvider: requestCredentialProvider,
|
|
216
|
+
hlc: params.hlc
|
|
217
|
+
})
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
}
|
package/lib/manager.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { EventEmitter } from '@enkaku/event';
|
|
2
1
|
import type { ConnectorSyncEventPayload, SyncBoundary } from '@kubun/connector';
|
|
3
2
|
import type { Logger } from '@kubun/logger';
|
|
3
|
+
import { EventEmitter } from '@sozai/event';
|
|
4
4
|
import type { ConnectorRegistry } from './registry.js';
|
|
5
5
|
type SyncEvents = {
|
|
6
6
|
sync: ConnectorSyncEventPayload;
|
package/lib/manager.js
CHANGED
|
@@ -1 +1,90 @@
|
|
|
1
|
-
import{EventEmitter
|
|
1
|
+
import { EventEmitter } from '@sozai/event';
|
|
2
|
+
export class ConnectorManager {
|
|
3
|
+
#registry;
|
|
4
|
+
#logger;
|
|
5
|
+
#activeGraphs = new Map();
|
|
6
|
+
#events = new EventEmitter();
|
|
7
|
+
#syncEvents = new EventEmitter();
|
|
8
|
+
#defaults;
|
|
9
|
+
constructor(params){
|
|
10
|
+
this.#registry = params.registry;
|
|
11
|
+
this.#logger = params.logger;
|
|
12
|
+
this.#defaults = {
|
|
13
|
+
boundary: params.defaults?.boundary ?? {
|
|
14
|
+
maxAge: 'P90D'
|
|
15
|
+
},
|
|
16
|
+
pollingInterval: params.defaults?.pollingInterval ?? 'PT5M'
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
get defaults() {
|
|
20
|
+
return this.#defaults;
|
|
21
|
+
}
|
|
22
|
+
activateForGraph(graphID, connectorNames) {
|
|
23
|
+
for (const name of connectorNames){
|
|
24
|
+
if (!this.#registry.has(name)) {
|
|
25
|
+
throw new Error(`Connector "${name}" is not registered`);
|
|
26
|
+
}
|
|
27
|
+
let graphSet = this.#activeGraphs.get(name);
|
|
28
|
+
const wasInactive = graphSet == null || graphSet.size === 0;
|
|
29
|
+
if (graphSet == null) {
|
|
30
|
+
graphSet = new Set();
|
|
31
|
+
this.#activeGraphs.set(name, graphSet);
|
|
32
|
+
}
|
|
33
|
+
graphSet.add(graphID);
|
|
34
|
+
if (wasInactive) {
|
|
35
|
+
this.#logger.info('Connector {name} activated by graph {graphID}', {
|
|
36
|
+
name,
|
|
37
|
+
graphID
|
|
38
|
+
});
|
|
39
|
+
this.#events.emit('connector:activated', {
|
|
40
|
+
connectorName: name,
|
|
41
|
+
graphID
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
deactivateForGraph(graphID, connectorNames) {
|
|
47
|
+
for (const name of connectorNames){
|
|
48
|
+
const graphSet = this.#activeGraphs.get(name);
|
|
49
|
+
if (graphSet == null) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
graphSet.delete(graphID);
|
|
53
|
+
if (graphSet.size === 0) {
|
|
54
|
+
this.#activeGraphs.delete(name);
|
|
55
|
+
this.#logger.info('Connector {name} deactivated', {
|
|
56
|
+
name
|
|
57
|
+
});
|
|
58
|
+
this.#events.emit('connector:deactivated', {
|
|
59
|
+
connectorName: name
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
isActive(connectorName) {
|
|
65
|
+
const graphSet = this.#activeGraphs.get(connectorName);
|
|
66
|
+
return graphSet != null && graphSet.size > 0;
|
|
67
|
+
}
|
|
68
|
+
getActiveGraphs(connectorName) {
|
|
69
|
+
const graphSet = this.#activeGraphs.get(connectorName);
|
|
70
|
+
return graphSet ? Array.from(graphSet) : [];
|
|
71
|
+
}
|
|
72
|
+
listActive() {
|
|
73
|
+
return Array.from(this.#activeGraphs.keys()).filter((name)=>this.isActive(name));
|
|
74
|
+
}
|
|
75
|
+
onConnectorActivated(callback) {
|
|
76
|
+
return this.#events.on('connector:activated', callback);
|
|
77
|
+
}
|
|
78
|
+
onConnectorDeactivated(callback) {
|
|
79
|
+
return this.#events.on('connector:deactivated', callback);
|
|
80
|
+
}
|
|
81
|
+
get syncEvents() {
|
|
82
|
+
return this.#syncEvents;
|
|
83
|
+
}
|
|
84
|
+
async emitSyncEvent(event) {
|
|
85
|
+
await this.#syncEvents.emit('sync', event);
|
|
86
|
+
}
|
|
87
|
+
onSyncEvent(callback) {
|
|
88
|
+
return this.#syncEvents.on('sync', callback);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/lib/oauth.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { Runtime } from '@enkaku/runtime';
|
|
2
1
|
import type { CredentialProvider, OAuthProviderDefinition } from '@kubun/connector';
|
|
2
|
+
import type { Runtime } from '@sozai/runtime';
|
|
3
|
+
import type { ConnectorAPI } from './api.js';
|
|
3
4
|
import type { ConnectorRegistry } from './registry.js';
|
|
4
5
|
import type { CompleteConnectorAuthInput, CompleteConnectorAuthOutput, StartConnectorAuthInput, StartConnectorAuthOutput } from './schema.js';
|
|
5
6
|
export type OAuthServiceParams = {
|
|
@@ -7,10 +8,11 @@ export type OAuthServiceParams = {
|
|
|
7
8
|
providers: Array<OAuthProviderDefinition>;
|
|
8
9
|
registry: ConnectorRegistry;
|
|
9
10
|
credentialProvider: CredentialProvider;
|
|
11
|
+
connectorAPI: ConnectorAPI;
|
|
10
12
|
};
|
|
11
13
|
export declare class OAuthService {
|
|
12
14
|
#private;
|
|
13
15
|
constructor(params: OAuthServiceParams);
|
|
14
|
-
startAuth(args: StartConnectorAuthInput): Promise<StartConnectorAuthOutput>;
|
|
15
|
-
completeAuth(args: CompleteConnectorAuthInput,
|
|
16
|
+
startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI): Promise<StartConnectorAuthOutput>;
|
|
17
|
+
completeAuth(args: CompleteConnectorAuthInput, credentialProvider?: CredentialProvider, connectorAPI?: ConnectorAPI): Promise<CompleteConnectorAuthOutput>;
|
|
16
18
|
}
|
package/lib/oauth.js
CHANGED
|
@@ -1 +1,137 @@
|
|
|
1
|
-
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
2
|
+
import { fromUTF, toB64U } from '@sozai/codec';
|
|
3
|
+
// Pending authorization records self-expire after this window; a callback
|
|
4
|
+
// presenting an older state is rejected and the row is swept on the next start.
|
|
5
|
+
const PENDING_AUTH_TTL_MS = 10 * 60 * 1000;
|
|
6
|
+
// PKCE requires unpadded base64url; toB64U emits `=` padding, so strip it.
|
|
7
|
+
function toB64UNoPad(bytes) {
|
|
8
|
+
return toB64U(bytes).replace(/=+$/, '');
|
|
9
|
+
}
|
|
10
|
+
export class OAuthService {
|
|
11
|
+
#credentialProvider;
|
|
12
|
+
#runtime;
|
|
13
|
+
#providers;
|
|
14
|
+
#registry;
|
|
15
|
+
#connectorAPI;
|
|
16
|
+
constructor(params){
|
|
17
|
+
this.#credentialProvider = params.credentialProvider;
|
|
18
|
+
this.#runtime = params.runtime;
|
|
19
|
+
this.#providers = params.providers;
|
|
20
|
+
this.#registry = params.registry;
|
|
21
|
+
this.#connectorAPI = params.connectorAPI;
|
|
22
|
+
}
|
|
23
|
+
async startAuth(args, ownerDID, connectorAPI) {
|
|
24
|
+
// Prefer the request-scoped API so the pending-auth write joins the mutation
|
|
25
|
+
// transaction; the base API would deadlock a single-connection SQLite tx.
|
|
26
|
+
const api = connectorAPI ?? this.#connectorAPI;
|
|
27
|
+
const provider = this.#providers.find((p)=>p.name === args.provider);
|
|
28
|
+
if (provider == null) {
|
|
29
|
+
throw new Error(`OAuth provider "${args.provider}" not found`);
|
|
30
|
+
}
|
|
31
|
+
if (provider.clientID == null) {
|
|
32
|
+
throw new Error(`OAuth provider "${args.provider}" is missing clientID`);
|
|
33
|
+
}
|
|
34
|
+
const requestWriteAccess = args.requestWriteAccess === true;
|
|
35
|
+
const scopes = new Set(provider.baseScopes ?? []);
|
|
36
|
+
const connectorNames = args.connectors ?? this.#registry.list();
|
|
37
|
+
for (const name of connectorNames){
|
|
38
|
+
const connector = this.#registry.get(name);
|
|
39
|
+
if (connector != null && connector.auth.provider === args.provider) {
|
|
40
|
+
const auth = connector.auth;
|
|
41
|
+
if (requestWriteAccess && auth.writeScopes != null) {
|
|
42
|
+
for (const scope of auth.writeScopes){
|
|
43
|
+
scopes.add(scope);
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
for (const scope of auth.scopes){
|
|
47
|
+
scopes.add(scope);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const state = this.#runtime.getRandomID();
|
|
53
|
+
const codeVerifier = toB64UNoPad(this.#runtime.getRandomValues(new Uint8Array(32)));
|
|
54
|
+
const codeChallenge = toB64UNoPad(sha256(fromUTF(codeVerifier)));
|
|
55
|
+
await api.createPendingAuth({
|
|
56
|
+
state,
|
|
57
|
+
codeVerifier,
|
|
58
|
+
provider: args.provider,
|
|
59
|
+
ownerDID,
|
|
60
|
+
createdAt: Date.now()
|
|
61
|
+
});
|
|
62
|
+
await api.deleteExpiredPendingAuth(Date.now() - PENDING_AUTH_TTL_MS);
|
|
63
|
+
const url = new URL(provider.authorizationEndpoint);
|
|
64
|
+
url.searchParams.set('client_id', provider.clientID);
|
|
65
|
+
url.searchParams.set('redirect_uri', args.redirectURL);
|
|
66
|
+
url.searchParams.set('response_type', 'code');
|
|
67
|
+
url.searchParams.set('scope', Array.from(scopes).join(' '));
|
|
68
|
+
url.searchParams.set('state', state);
|
|
69
|
+
url.searchParams.set('code_challenge', codeChallenge);
|
|
70
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
71
|
+
url.searchParams.set('access_type', 'offline');
|
|
72
|
+
// select_account forces the account chooser instead of letting Google
|
|
73
|
+
// auto-resolve the session account (authuser) — multi-account sessions
|
|
74
|
+
// otherwise intermittently dead-end on accounts.google.com/info/unknownerror.
|
|
75
|
+
// consent keeps the refresh-token guarantee for access_type=offline.
|
|
76
|
+
url.searchParams.set('prompt', 'select_account consent');
|
|
77
|
+
return {
|
|
78
|
+
url: url.toString(),
|
|
79
|
+
state
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async completeAuth(args, credentialProvider, connectorAPI) {
|
|
83
|
+
// Consume via the request-scoped API so the single-use delete joins the
|
|
84
|
+
// mutation transaction rather than deadlocking a single-connection SQLite tx.
|
|
85
|
+
const api = connectorAPI ?? this.#connectorAPI;
|
|
86
|
+
const provider = this.#providers.find((p)=>p.name === args.provider);
|
|
87
|
+
if (provider == null) {
|
|
88
|
+
throw new Error(`OAuth provider "${args.provider}" not found`);
|
|
89
|
+
}
|
|
90
|
+
if (provider.clientID == null || provider.clientSecret == null) {
|
|
91
|
+
throw new Error(`OAuth provider "${args.provider}" is missing clientID or clientSecret`);
|
|
92
|
+
}
|
|
93
|
+
// Single-use consume rejects CSRF (state we never issued), replay (already
|
|
94
|
+
// consumed), and unknown callbacks in one check.
|
|
95
|
+
const pending = await api.consumePendingAuth(args.state);
|
|
96
|
+
if (pending == null) {
|
|
97
|
+
throw new Error('Invalid or expired OAuth state');
|
|
98
|
+
}
|
|
99
|
+
if (Date.now() - pending.createdAt > PENDING_AUTH_TTL_MS) {
|
|
100
|
+
throw new Error('OAuth state expired');
|
|
101
|
+
}
|
|
102
|
+
if (pending.provider !== args.provider) {
|
|
103
|
+
throw new Error('OAuth state provider mismatch');
|
|
104
|
+
}
|
|
105
|
+
const response = await this.#runtime.fetch(provider.tokenEndpoint, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: {
|
|
108
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
109
|
+
},
|
|
110
|
+
body: new URLSearchParams({
|
|
111
|
+
grant_type: 'authorization_code',
|
|
112
|
+
code: args.code,
|
|
113
|
+
redirect_uri: args.redirectURL,
|
|
114
|
+
client_id: provider.clientID,
|
|
115
|
+
client_secret: provider.clientSecret,
|
|
116
|
+
code_verifier: pending.codeVerifier
|
|
117
|
+
})
|
|
118
|
+
});
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(`Token exchange failed: ${response.statusText}`);
|
|
121
|
+
}
|
|
122
|
+
const tokenData = await response.json();
|
|
123
|
+
const scopes = tokenData.scope?.split(' ') ?? [];
|
|
124
|
+
// Bind the credential to the owner that initiated the flow, not the ambient
|
|
125
|
+
// viewer completing the callback, so a stolen code cannot land under another DID.
|
|
126
|
+
await (credentialProvider ?? this.#credentialProvider).set(args.provider, pending.ownerDID, {
|
|
127
|
+
accessToken: tokenData.access_token,
|
|
128
|
+
refreshToken: tokenData.refresh_token,
|
|
129
|
+
expiresAt: tokenData.expires_in != null ? new Date(Date.now() + tokenData.expires_in * 1000) : undefined,
|
|
130
|
+
scopes
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
providerName: args.provider,
|
|
134
|
+
scopes
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
package/lib/registry.js
CHANGED
|
@@ -1 +1,24 @@
|
|
|
1
|
-
export class ConnectorRegistry
|
|
1
|
+
export class ConnectorRegistry {
|
|
2
|
+
#connectors = new Map();
|
|
3
|
+
register(connector) {
|
|
4
|
+
if (this.#connectors.has(connector.name)) {
|
|
5
|
+
throw new Error(`Connector "${connector.name}" is already registered`);
|
|
6
|
+
}
|
|
7
|
+
this.#connectors.set(connector.name, connector);
|
|
8
|
+
}
|
|
9
|
+
unregister(name) {
|
|
10
|
+
this.#connectors.delete(name);
|
|
11
|
+
}
|
|
12
|
+
get(name) {
|
|
13
|
+
return this.#connectors.get(name);
|
|
14
|
+
}
|
|
15
|
+
has(name) {
|
|
16
|
+
return this.#connectors.has(name);
|
|
17
|
+
}
|
|
18
|
+
list() {
|
|
19
|
+
return Array.from(this.#connectors.keys());
|
|
20
|
+
}
|
|
21
|
+
getAll() {
|
|
22
|
+
return Array.from(this.#connectors.values());
|
|
23
|
+
}
|
|
24
|
+
}
|
package/lib/schema.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ConnectorSyncEventPayload } from '@kubun/connector';
|
|
2
2
|
import type { SchemaExtension } from '@kubun/engine';
|
|
3
|
+
import type { EventEmitter } from '@sozai/event';
|
|
3
4
|
import type { ConnectorRegistry } from './registry.js';
|
|
5
|
+
import type { SignedDocumentWriter } from './sync/processor.js';
|
|
4
6
|
/**
|
|
5
7
|
* Combined connector state returned by the `connector` and `connectors` queries.
|
|
6
8
|
* Mirrors `ConnectorSyncStateResult` from `@kubun/graphql/src/context.ts`.
|
|
@@ -32,7 +34,7 @@ export type CompleteConnectorAuthInput = {
|
|
|
32
34
|
provider: string;
|
|
33
35
|
code: string;
|
|
34
36
|
redirectURL: string;
|
|
35
|
-
state
|
|
37
|
+
state: string;
|
|
36
38
|
};
|
|
37
39
|
export type CompleteConnectorAuthOutput = {
|
|
38
40
|
providerName: string;
|
|
@@ -72,6 +74,18 @@ export type ConnectorActionResult = {
|
|
|
72
74
|
entity: Record<string, unknown> | null;
|
|
73
75
|
error: ConnectorActionError | null;
|
|
74
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* A live viewer→server delegated write capability, resolved to the connector
|
|
79
|
+
* it authorizes. Surfaced to apps so a viewer can see and revoke the writes it
|
|
80
|
+
* has delegated to the server.
|
|
81
|
+
*/
|
|
82
|
+
export type ConnectorWriteGrant = {
|
|
83
|
+
connector: string;
|
|
84
|
+
provider: string;
|
|
85
|
+
modelURNs: Array<string>;
|
|
86
|
+
exp: number;
|
|
87
|
+
jti: string;
|
|
88
|
+
};
|
|
75
89
|
/**
|
|
76
90
|
* Connector sync event payload for subscriptions (GraphQL result type).
|
|
77
91
|
*/
|
|
@@ -89,6 +103,23 @@ export type ConnectorSyncEventResult = {
|
|
|
89
103
|
* Transform a raw `ConnectorSyncEventPayload` into the GraphQL result shape.
|
|
90
104
|
*/
|
|
91
105
|
export declare function toConnectorSyncEventResult(event: ConnectorSyncEventPayload): ConnectorSyncEventResult;
|
|
106
|
+
/**
|
|
107
|
+
* Emitter shape used by `ConnectorManager` to broadcast sync events.
|
|
108
|
+
*/
|
|
109
|
+
export type ConnectorSyncEventEmitter = EventEmitter<{
|
|
110
|
+
sync: ConnectorSyncEventPayload;
|
|
111
|
+
}>;
|
|
112
|
+
/**
|
|
113
|
+
* Subscribe to a connector sync-event emitter, yielding GraphQL result shapes.
|
|
114
|
+
*
|
|
115
|
+
* Implemented as a manual async iterator rather than an `async function*` wrapper
|
|
116
|
+
* so that a consumer's `return()` forwards directly to the underlying emitter
|
|
117
|
+
* generator, removing its listener immediately. A `for await` wrapper instead
|
|
118
|
+
* queues the `return()` behind a parked `next()` between events, so the emitter
|
|
119
|
+
* listener would leak until the next event arrives — for an idle subscription,
|
|
120
|
+
* potentially never.
|
|
121
|
+
*/
|
|
122
|
+
export declare function subscribeToConnectorSyncEvents(emitter: ConnectorSyncEventEmitter, connector?: string | null): AsyncIterableIterator<ConnectorSyncEventResult>;
|
|
92
123
|
/**
|
|
93
124
|
* Per-request context methods for the Connector query and mutation fields.
|
|
94
125
|
* Provided by the context factory when registry + credentials are configured.
|
|
@@ -103,7 +134,18 @@ export type ConnectorQueryContext = {
|
|
|
103
134
|
completeAuth: (args: CompleteConnectorAuthInput) => Promise<CompleteConnectorAuthOutput>;
|
|
104
135
|
triggerSync: (args: SyncTriggerInput) => Promise<SyncTriggerOutput>;
|
|
105
136
|
subscribeToSyncEvents: (connector?: string | null) => AsyncIterable<ConnectorSyncEventResult>;
|
|
106
|
-
executeAction?: (args: ConnectorActionInput) => Promise<ConnectorActionResult>;
|
|
137
|
+
executeAction?: (args: ConnectorActionInput, writeDocument: SignedDocumentWriter) => Promise<ConnectorActionResult>;
|
|
138
|
+
grantWriteCapability?: (args: {
|
|
139
|
+
connector: string;
|
|
140
|
+
tokens: Array<string>;
|
|
141
|
+
}) => Promise<boolean>;
|
|
142
|
+
revokeConnectorWriteCapability?: (args: {
|
|
143
|
+
connector: string;
|
|
144
|
+
}) => Promise<boolean>;
|
|
145
|
+
disconnectProvider?: (args: {
|
|
146
|
+
provider: string;
|
|
147
|
+
}) => Promise<boolean>;
|
|
148
|
+
connectorWriteGrants?: () => Promise<Array<ConnectorWriteGrant>>;
|
|
107
149
|
};
|
|
108
150
|
declare module '@kubun/graphql' {
|
|
109
151
|
interface PluginContextMap {
|