@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/index.js CHANGED
@@ -1 +1,223 @@
1
- import{fromEmitter as e}from"@enkaku/generator";import{connectorStoreDefinition as t}from"@kubun/store-connector";import{executeAction as r}from"./action.js";import{createConnectorAPI as n}from"./api.js";import{DBCredentialProvider as o}from"./credential.js";import{ConnectorManager as c}from"./manager.js";import{OAuthService as s}from"./oauth.js";import{ConnectorRegistry as i}from"./registry.js";import{createConnectorSchemaExtension as a,toConnectorSyncEventResult as u}from"./schema.js";import{orchestrateSync as l}from"./sync/orchestrate.js";import{DBSyncStateStore as m}from"./sync/state.js";export{executeAction}from"./action.js";export{ConnectorManager}from"./manager.js";export{OAuthService}from"./oauth.js";export{ConnectorRegistry}from"./registry.js";export{createConnectorSchemaExtension,toConnectorSyncEventResult}from"./schema.js";export{SyncEngine}from"./sync/engine.js";export{orchestrateSync}from"./sync/orchestrate.js";export{EntityProcessor}from"./sync/processor.js";export{DBSyncStateStore}from"./sync/state.js";async function p(e,t,r,n,o){let c=t.get(o);if(null==c)throw Error(`Connector "${o}" not found`);let s=await e.getSyncState(o,n),i=!1,a=!1,u=null;if("device"!==c.auth.provider){let e=await r.get(c.auth.provider,n);if(null!=e){i=!0,null!=e.expiresAt&&(u=e.expiresAt.toISOString());let t=c.auth;a=null==t.writeScopes||t.writeScopes.every(t=>e.scopes.includes(t))}}return null==s?{name:o,status:"IDLE",authenticated:i,hasWriteAccess:a,authExpiresAt:u,lastSyncedAt:null,entityCount:null,error:null}:{name:o,status:"idle"===s.status?"IDLE":"syncing"===s.status?"SYNCING":"ERROR",authenticated:i,hasWriteAccess:a,authExpiresAt:u,lastSyncedAt:s.lastSyncedAt??null,entityCount:s.entityCount??null,error:s.error??null}}export function createConnectorPlugin(y){let f=y.providers??[];return d=>{d.db.register(t);let g=new i;for(let e of y.connectors)g.register(e);let S=n({stores:d.db}),D=new o({api:S,runtime:d.runtime,providers:f}),h=new m({api:S}),w=new c({registry:g,logger:d.getLogger("connector"),defaults:y.defaults}),j=new s({runtime:d.runtime,providers:f,registry:g,credentialProvider:D});return{name:"connector",schemaExtension:e=>a({registry:g,config:e}),api:S,createContextFactory:()=>(t,c)=>{let s=n({stores:c}),i=new o({api:s,runtime:d.runtime,providers:f});return{getState:e=>p(s,g,i,t.viewerDID,e),getStates:()=>Promise.all(g.list().map(e=>p(s,g,i,t.viewerDID,e))),startAuth:e=>j.startAuth(e),completeAuth:e=>j.completeAuth(e,t.viewerDID,i),triggerSync:async e=>g.has(e.connector)?(c.onCommit(()=>{l({connectorName:e.connector,full:e.full??!1,registry:g,syncEventEmitter:w,stateStore:h,credentialProvider:D,ownerDID:t.viewerDID,stores:d.db,boundary:y.defaults?.boundary}).catch(()=>{})}),{status:"STARTED",connectorName:e.connector}):{status:"ERROR",connectorName:e.connector},subscribeToSyncEvents:t=>{let r=null!=t?e=>e.connectorName===t:void 0,n=e(w.syncEvents,"sync",{filter:r});return async function*(){for await(let e of n)yield u(e)}()},executeAction:async e=>{try{let n=await r({connector:e.connector,action:e.action,model:e.model,input:e.input,sourceID:e.sourceID},{stores:c,registry:g,credentialProvider:i,ownerDID:t.viewerDID});return{documentID:n.documentID,entity:n.entity,error:null}}catch(t){let e=t.code??"API_ERROR";return{documentID:null,entity:null,error:{code:e,message:t instanceof Error?t.message:String(t),requiredScopes:t.requiredScopes}}}}}}}}}export{o as DBCredentialProvider};
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 as t}from"@enkaku/event";export class ConnectorManager{#t;#e;#n=new Map;#r=new t;#s=new t;#i;constructor(t){this.#t=t.registry,this.#e=t.logger,this.#i={boundary:t.defaults?.boundary??{maxAge:"P90D"},pollingInterval:t.defaults?.pollingInterval??"PT5M"}}get defaults(){return this.#i}activateForGraph(t,e){for(let n of e){if(!this.#t.has(n))throw Error(`Connector "${n}" is not registered`);let e=this.#n.get(n),r=null==e||0===e.size;null==e&&(e=new Set,this.#n.set(n,e)),e.add(t),r&&(this.#e.info("Connector {name} activated by graph {graphID}",{name:n,graphID:t}),this.#r.emit("connector:activated",{connectorName:n,graphID:t}))}}deactivateForGraph(t,e){for(let n of e){let e=this.#n.get(n);null!=e&&(e.delete(t),0===e.size&&(this.#n.delete(n),this.#e.info("Connector {name} deactivated",{name:n}),this.#r.emit("connector:deactivated",{connectorName:n})))}}isActive(t){let e=this.#n.get(t);return null!=e&&e.size>0}getActiveGraphs(t){let e=this.#n.get(t);return e?Array.from(e):[]}listActive(){return Array.from(this.#n.keys()).filter(t=>this.isActive(t))}onConnectorActivated(t){return this.#r.on("connector:activated",t)}onConnectorDeactivated(t){return this.#r.on("connector:deactivated",t)}get syncEvents(){return this.#s}async emitSyncEvent(t){await this.#s.emit("sync",t)}onSyncEvent(t){return this.#s.on("sync",t)}}
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, viewerDID: string, credentialProvider?: CredentialProvider): Promise<CompleteConnectorAuthOutput>;
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
- export class OAuthService{#e;#r;#t;#i;constructor(e){this.#e=e.credentialProvider,this.#r=e.runtime,this.#t=e.providers,this.#i=e.registry}async startAuth(e){let r=this.#t.find(r=>r.name===e.provider);if(null==r)throw Error(`OAuth provider "${e.provider}" not found`);if(null==r.clientID)throw Error(`OAuth provider "${e.provider}" is missing clientID`);let t=!0===e.requestWriteAccess,i=new Set(r.baseScopes??[]);for(let r of e.connectors??this.#i.list()){let o=this.#i.get(r);if(null!=o&&o.auth.provider===e.provider){let e=o.auth;if(t&&null!=e.writeScopes)for(let r of e.writeScopes)i.add(r);else for(let r of e.scopes)i.add(r)}}let o=JSON.stringify({nonce:this.#r.getRandomID(),provider:e.provider}),s=new URL(r.authorizationEndpoint);return s.searchParams.set("client_id",r.clientID),s.searchParams.set("redirect_uri",e.redirectURL),s.searchParams.set("response_type","code"),s.searchParams.set("scope",Array.from(i).join(" ")),s.searchParams.set("state",o),s.searchParams.set("access_type","offline"),s.searchParams.set("prompt","consent"),{url:s.toString(),state:o}}async completeAuth(e,r,t){let i=this.#t.find(r=>r.name===e.provider);if(null==i)throw Error(`OAuth provider "${e.provider}" not found`);if(null==i.clientID||null==i.clientSecret)throw Error(`OAuth provider "${e.provider}" is missing clientID or clientSecret`);let o=await this.#r.fetch(i.tokenEndpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:e.code,redirect_uri:e.redirectURL,client_id:i.clientID,client_secret:i.clientSecret})});if(!o.ok)throw Error(`Token exchange failed: ${o.statusText}`);let s=await o.json(),n=s.scope?.split(" ")??[];return await (t??this.#e).set(e.provider,r,{accessToken:s.access_token,refreshToken:s.refresh_token,expiresAt:null!=s.expires_in?new Date(Date.now()+1e3*s.expires_in):void 0,scopes:n}),{providerName:e.provider,scopes:n}}}
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{#e=new Map;register(e){if(this.#e.has(e.name))throw Error(`Connector "${e.name}" is already registered`);this.#e.set(e.name,e)}unregister(e){this.#e.delete(e)}get(e){return this.#e.get(e)}has(e){return this.#e.has(e)}list(){return Array.from(this.#e.keys())}getAll(){return Array.from(this.#e.values())}}
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?: string | null;
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 {