@kubun/plugin-connector 0.13.1 → 0.14.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 +2 -1
- package/lib/api.d.ts +2 -54
- package/lib/api.js +1 -116
- package/lib/credential.d.ts +2 -22
- package/lib/credential.js +4 -129
- package/lib/index.d.ts +6 -3
- package/lib/index.js +228 -51
- package/lib/oauth.d.ts +5 -3
- package/lib/oauth.js +7 -2
- package/lib/schema.d.ts +5 -0
- package/lib/schema.js +53 -0
- package/lib/sync/orchestrate.d.ts +2 -1
- package/lib/sync/workflow.d.ts +4 -1
- package/lib/sync/workflow.js +29 -6
- package/lib/write-grants.d.ts +1 -1
- package/package.json +36 -26
package/lib/action.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { EntityRecord, WriteAttachment } from '@kubun/connector';
|
|
2
|
+
import type { CredentialProvider } from '@kubun/credential-types';
|
|
2
3
|
import type { StoreProvider } from '@kubun/db';
|
|
3
4
|
import type { Logger } from '@kubun/logger';
|
|
4
5
|
import type { ConnectorRegistry } from './registry.js';
|
package/lib/api.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type MethodRegistry } from '@kokuin/token';
|
|
2
|
-
import { type CredentialManager } from '@kubun/credential';
|
|
3
1
|
import type { StoreProvider } from '@kubun/db';
|
|
4
2
|
import type { Logger } from '@kubun/logger';
|
|
5
3
|
import { type PendingAuthRecord } from '@kubun/store-connector';
|
|
4
|
+
export type { CreateCredentialAPIParams, CredentialAPI } from '@kubun/credential';
|
|
5
|
+
export { createCredentialAPI } from '@kubun/credential';
|
|
6
6
|
export type SyncStateData = {
|
|
7
7
|
connectorName: string;
|
|
8
8
|
ownerDID: string;
|
|
@@ -28,70 +28,18 @@ export type SetSyncStateParams = {
|
|
|
28
28
|
* two `owner_did` columns are equality keys, so an un-normalized one files the
|
|
29
29
|
* same account under two rows: a credential invisible under one spelling and
|
|
30
30
|
* un-revokable under the other, and a sync lease held twice at once.
|
|
31
|
-
*
|
|
32
|
-
* `ownerWrappableDID` is NOT normalized — see {@link createConnectorAPI}.
|
|
33
31
|
*/
|
|
34
32
|
export type ConnectorAPI = {
|
|
35
33
|
getSyncState: (connectorName: string, ownerDID: string) => Promise<SyncStateData | null>;
|
|
36
34
|
setSyncState: (connectorName: string, ownerDID: string, state: SetSyncStateParams) => Promise<SyncStateData>;
|
|
37
35
|
deleteSyncState: (connectorName: string, ownerDID: string) => Promise<void>;
|
|
38
36
|
listSyncStates: (connectorName: string) => Promise<Array<SyncStateData>>;
|
|
39
|
-
getCredential: (providerName: string, ownerDID: string) => Promise<string | null>;
|
|
40
|
-
getCredentialProvenance: (providerName: string, ownerDID: string) => Promise<{
|
|
41
|
-
updatedAt: string | null;
|
|
42
|
-
writerDID: string | null;
|
|
43
|
-
} | null>;
|
|
44
|
-
/**
|
|
45
|
-
* Re-encrypt an existing credential. Refuses when none exists, because
|
|
46
|
-
* creating one is a different decision — see `createCredential`.
|
|
47
|
-
*/
|
|
48
|
-
setCredential: (providerName: string, ownerDID: string, credential: string) => Promise<void>;
|
|
49
|
-
/**
|
|
50
|
-
* Mint the key, its two wrappings and the entry, and record the pointer.
|
|
51
|
-
*
|
|
52
|
-
* Split from `setCredential` so the headless refresh path cannot mint a key.
|
|
53
|
-
* A minted key fixes who will ever be able to read it, and a refresh running
|
|
54
|
-
* with no owner in hand would mint one wrapped to the server alone — locking
|
|
55
|
-
* the account owner out of their own credential with nothing failing.
|
|
56
|
-
*/
|
|
57
|
-
createCredential: (params: {
|
|
58
|
-
providerName: string;
|
|
59
|
-
ownerDID: string;
|
|
60
|
-
ownerWrappableDID: string;
|
|
61
|
-
controllerWrappableDID?: string;
|
|
62
|
-
credential: string;
|
|
63
|
-
}) => Promise<void>;
|
|
64
|
-
deleteCredential: (providerName: string, ownerDID: string) => Promise<void>;
|
|
65
37
|
createPendingAuth: (record: PendingAuthRecord) => Promise<void>;
|
|
66
38
|
consumePendingAuth: (state: string) => Promise<PendingAuthRecord | null>;
|
|
67
39
|
deleteExpiredPendingAuth: (cutoffMs: number) => Promise<void>;
|
|
68
|
-
/**
|
|
69
|
-
* The controller resolvers for this API's (request-scoped) transaction
|
|
70
|
-
* provider — the exact `methods` the credential mint passes to `createKey`.
|
|
71
|
-
* Exposed so the OAuth pre-flight can ask `canWrapTo` with the same resolver
|
|
72
|
-
* the mint uses, rather than failing closed on a `did:kokuin:` owner whose
|
|
73
|
-
* agreement key is resolvable only through the controller resolver.
|
|
74
|
-
*/
|
|
75
|
-
getControllerMethods: () => MethodRegistry;
|
|
76
40
|
};
|
|
77
41
|
export type CreateConnectorAPIParams = {
|
|
78
42
|
stores: StoreProvider;
|
|
79
43
|
logger: Logger;
|
|
80
|
-
/**
|
|
81
|
-
* Takes the `StoreProvider` rather than a manager, because during a mutation
|
|
82
|
-
* that provider is the transaction and a manager built over the base one
|
|
83
|
-
* would read outside it.
|
|
84
|
-
*/
|
|
85
|
-
getCredentialManager: (stores: StoreProvider) => Promise<CredentialManager>;
|
|
86
|
-
/** The engine's own DID, in a form that can be encrypted to. */
|
|
87
|
-
serverWrappableDID: string;
|
|
88
|
-
/**
|
|
89
|
-
* Resolvers for wrapping recipients that carry no key material of their own
|
|
90
|
-
* (`did:kokuin:`), scoped to the request's transaction provider so a resolve
|
|
91
|
-
* reads the mutation's tx, never a second connection. A self-contained
|
|
92
|
-
* recipient resolves with an empty registry, so callers with no controller
|
|
93
|
-
* recipient in play still pass `() => []`.
|
|
94
|
-
*/
|
|
95
|
-
getControllerMethods: (stores: StoreProvider) => MethodRegistry;
|
|
96
44
|
};
|
|
97
45
|
export declare function createConnectorAPI(params: CreateConnectorAPIParams): ConnectorAPI;
|
package/lib/api.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { normalizeDID } from '@kokuin/token';
|
|
2
|
-
import { CredentialSupersededBranch } from '@kubun/credential';
|
|
3
2
|
import { getConnectorStore } from '@kubun/store-connector';
|
|
4
|
-
|
|
3
|
+
export { createCredentialAPI } from '@kubun/credential';
|
|
5
4
|
export function createConnectorAPI(params) {
|
|
6
5
|
const getStore = ()=>getConnectorStore(params.stores);
|
|
7
|
-
const getCredentials = ()=>params.getCredentialManager(params.stores);
|
|
8
6
|
return {
|
|
9
7
|
async getSyncState (connectorName, ownerDID) {
|
|
10
8
|
const store = await getStore();
|
|
@@ -68,116 +66,6 @@ export function createConnectorAPI(params) {
|
|
|
68
66
|
leaseExpiresAt: entry.leaseExpiresAt ?? null
|
|
69
67
|
}));
|
|
70
68
|
},
|
|
71
|
-
async getCredential (providerName, ownerDID) {
|
|
72
|
-
const store = await getStore();
|
|
73
|
-
const entryID = await store.getCredentialEntryID(providerName, normalizeDID(ownerDID));
|
|
74
|
-
if (entryID == null) return null;
|
|
75
|
-
try {
|
|
76
|
-
return toUTF(await (await getCredentials()).readEntry(entryID));
|
|
77
|
-
} catch (error) {
|
|
78
|
-
if (error instanceof CredentialSupersededBranch) {
|
|
79
|
-
params.logger.warn('Credential entry {entryID} for provider {providerName} belongs to a superseded branch', {
|
|
80
|
-
providerName,
|
|
81
|
-
entryID
|
|
82
|
-
});
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
throw error;
|
|
86
|
-
}
|
|
87
|
-
},
|
|
88
|
-
async getCredentialProvenance (providerName, ownerDID) {
|
|
89
|
-
// Reflects the current pointer, not the winning branch — no superseded-branch guard
|
|
90
|
-
// (it never decrypts). Callers must gate on getCredential() != null so a loser-branch
|
|
91
|
-
// pointer's provenance is never surfaced.
|
|
92
|
-
const store = await getStore();
|
|
93
|
-
const entryID = await store.getCredentialEntryID(providerName, normalizeDID(ownerDID));
|
|
94
|
-
if (entryID == null) return null;
|
|
95
|
-
return (await getCredentials()).getEntryProvenance(entryID);
|
|
96
|
-
},
|
|
97
|
-
async setCredential (providerName, ownerDID, credential) {
|
|
98
|
-
const store = await getStore();
|
|
99
|
-
const entryID = await store.getCredentialEntryID(providerName, normalizeDID(ownerDID));
|
|
100
|
-
if (entryID == null) {
|
|
101
|
-
throw new Error(`No connector credential to update for provider "${providerName}"; create it through the authorization flow`);
|
|
102
|
-
}
|
|
103
|
-
// Opens the key through the server's own wrapping and rewrites the entry
|
|
104
|
-
// under it. No key is minted and no wrapping is touched, so a refresh
|
|
105
|
-
// cannot change who can read the credential.
|
|
106
|
-
await (await getCredentials()).updateEntry(entryID, fromUTF(credential));
|
|
107
|
-
},
|
|
108
|
-
async createCredential (input) {
|
|
109
|
-
const { providerName, ownerDID, ownerWrappableDID, credential } = input;
|
|
110
|
-
const store = await getStore();
|
|
111
|
-
const credentials = await getCredentials();
|
|
112
|
-
// Wrapping to a DID needs only that DID's published key, so an engine that
|
|
113
|
-
// cannot decrypt would mint this key perfectly and then be unable to read
|
|
114
|
-
// it — a failure that surfaces at the first headless sync, far from here.
|
|
115
|
-
// This is where `cipher == null` used to fail closed, and it still does.
|
|
116
|
-
if (!credentials.availableFactors().includes('did')) {
|
|
117
|
-
throw new Error(`Cannot create a connector credential for "${providerName}": this engine's identity cannot decrypt, so it could never read back what it stored`);
|
|
118
|
-
}
|
|
119
|
-
// Two wrappings from the first write, not one plus a later grant: the
|
|
120
|
-
// server has to read this to run sync, and the owner has to be able to
|
|
121
|
-
// rotate and revoke it. Owner is authority, a different question from who
|
|
122
|
-
// can decrypt: when a controller is supplied it is the owner, so a device
|
|
123
|
-
// revoke goes through the controller's capability chain rather than the
|
|
124
|
-
// device self-authorizing; absent one the device owns its own key.
|
|
125
|
-
//
|
|
126
|
-
// The two spellings below are deliberately different questions about the
|
|
127
|
-
// same identity, and NOT interchangeable: `owner_did` is an equality key
|
|
128
|
-
// and so is normalized, while a recipient is encrypted to and must keep
|
|
129
|
-
// its wrappable form. Normalizing a `did:peer:4` recipient hands the short
|
|
130
|
-
// form to `deriveSharedSecret`, which resolves no document and no
|
|
131
|
-
// agreement key — do not fold these together.
|
|
132
|
-
const wrappings = [
|
|
133
|
-
[
|
|
134
|
-
{
|
|
135
|
-
kind: 'did',
|
|
136
|
-
recipientDID: ownerWrappableDID
|
|
137
|
-
}
|
|
138
|
-
],
|
|
139
|
-
[
|
|
140
|
-
{
|
|
141
|
-
kind: 'did',
|
|
142
|
-
recipientDID: params.serverWrappableDID
|
|
143
|
-
}
|
|
144
|
-
]
|
|
145
|
-
];
|
|
146
|
-
// A recovery recipient (e.g. a controller / seed-holder), so the credential
|
|
147
|
-
// survives device loss. Opt-in: absent when the caller supplied none. Kept
|
|
148
|
-
// in its wrappable form for the same reason as the two above — it is
|
|
149
|
-
// encrypted to, not compared against.
|
|
150
|
-
if (input.controllerWrappableDID != null) {
|
|
151
|
-
wrappings.push([
|
|
152
|
-
{
|
|
153
|
-
kind: 'did',
|
|
154
|
-
recipientDID: input.controllerWrappableDID
|
|
155
|
-
}
|
|
156
|
-
]);
|
|
157
|
-
}
|
|
158
|
-
// The controller owns the key when supplied (its canonical DID equals the
|
|
159
|
-
// normalized wrappable form); the account pointer below stays keyed on the
|
|
160
|
-
// device — ownership and account scoping are separate concerns.
|
|
161
|
-
const keyOwnerDID = input.controllerWrappableDID != null ? normalizeDID(input.controllerWrappableDID) : normalizeDID(ownerDID);
|
|
162
|
-
const keyID = await credentials.createKey({
|
|
163
|
-
ownerDID: keyOwnerDID,
|
|
164
|
-
wrappings,
|
|
165
|
-
methods: params.getControllerMethods(params.stores)
|
|
166
|
-
});
|
|
167
|
-
const entryID = await credentials.putEntry(keyID, fromUTF(credential));
|
|
168
|
-
await store.setCredentialEntryID(providerName, normalizeDID(ownerDID), entryID);
|
|
169
|
-
},
|
|
170
|
-
async deleteCredential (providerName, ownerDID) {
|
|
171
|
-
const store = await getStore();
|
|
172
|
-
const owner = normalizeDID(ownerDID);
|
|
173
|
-
const entryID = await store.getCredentialEntryID(providerName, owner);
|
|
174
|
-
await store.deleteCredentialEntryID(providerName, owner);
|
|
175
|
-
if (entryID != null) {
|
|
176
|
-
// The pointer and the entry go together. Leaving the entry would keep
|
|
177
|
-
// the token readable by anyone who kept its id after a disconnect.
|
|
178
|
-
await (await getCredentials()).deleteEntry(entryID);
|
|
179
|
-
}
|
|
180
|
-
},
|
|
181
69
|
async createPendingAuth (record) {
|
|
182
70
|
const store = await getStore();
|
|
183
71
|
await store.createPendingAuth(record);
|
|
@@ -189,9 +77,6 @@ export function createConnectorAPI(params) {
|
|
|
189
77
|
async deleteExpiredPendingAuth (cutoffMs) {
|
|
190
78
|
const store = await getStore();
|
|
191
79
|
await store.deleteExpiredPendingAuth(cutoffMs);
|
|
192
|
-
},
|
|
193
|
-
getControllerMethods () {
|
|
194
|
-
return params.getControllerMethods(params.stores);
|
|
195
80
|
}
|
|
196
81
|
};
|
|
197
82
|
}
|
package/lib/credential.d.ts
CHANGED
|
@@ -1,22 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import type { ConnectorAPI } from './api.js';
|
|
4
|
-
export type DBCredentialProviderParams = {
|
|
5
|
-
api: ConnectorAPI;
|
|
6
|
-
runtime: Runtime;
|
|
7
|
-
providers: Array<OAuthProviderDefinition>;
|
|
8
|
-
bufferSeconds?: number;
|
|
9
|
-
};
|
|
10
|
-
export declare class DBCredentialProvider implements CredentialProvider {
|
|
11
|
-
#private;
|
|
12
|
-
constructor(params: DBCredentialProviderParams);
|
|
13
|
-
get(providerName: string, ownerDID: string): Promise<Credential | null>;
|
|
14
|
-
set(params: {
|
|
15
|
-
providerName: string;
|
|
16
|
-
ownerDID: string;
|
|
17
|
-
credential: Credential;
|
|
18
|
-
ownerWrappableDID?: string;
|
|
19
|
-
controllerWrappableDID?: string;
|
|
20
|
-
}): Promise<void>;
|
|
21
|
-
delete(providerName: string, ownerDID: string): Promise<void>;
|
|
22
|
-
}
|
|
1
|
+
export type { DBCredentialProviderParams } from '@kubun/credential';
|
|
2
|
+
export { DBCredentialProvider } from '@kubun/credential';
|
package/lib/credential.js
CHANGED
|
@@ -1,129 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
scopes: credential.scopes
|
|
6
|
-
};
|
|
7
|
-
if (credential.refreshToken != null) {
|
|
8
|
-
serialized.refreshToken = credential.refreshToken;
|
|
9
|
-
}
|
|
10
|
-
if (credential.expiresAt != null) {
|
|
11
|
-
serialized.expiresAt = credential.expiresAt.toISOString();
|
|
12
|
-
}
|
|
13
|
-
if (credential.accountLabel != null) {
|
|
14
|
-
serialized.accountLabel = credential.accountLabel;
|
|
15
|
-
}
|
|
16
|
-
if (credential.metadata != null) {
|
|
17
|
-
serialized.metadata = credential.metadata;
|
|
18
|
-
}
|
|
19
|
-
return JSON.stringify(serialized);
|
|
20
|
-
}
|
|
21
|
-
function deserializeCredential(raw) {
|
|
22
|
-
// ParseJSONResultsPlugin may have already parsed the JSON string
|
|
23
|
-
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
24
|
-
const credential = {
|
|
25
|
-
accessToken: parsed.accessToken,
|
|
26
|
-
scopes: parsed.scopes
|
|
27
|
-
};
|
|
28
|
-
if (parsed.refreshToken != null) {
|
|
29
|
-
credential.refreshToken = parsed.refreshToken;
|
|
30
|
-
}
|
|
31
|
-
if (parsed.expiresAt != null) {
|
|
32
|
-
credential.expiresAt = new Date(parsed.expiresAt);
|
|
33
|
-
}
|
|
34
|
-
if (parsed.accountLabel != null) {
|
|
35
|
-
credential.accountLabel = parsed.accountLabel;
|
|
36
|
-
}
|
|
37
|
-
if (parsed.metadata != null) {
|
|
38
|
-
credential.metadata = parsed.metadata;
|
|
39
|
-
}
|
|
40
|
-
return credential;
|
|
41
|
-
}
|
|
42
|
-
export class DBCredentialProvider {
|
|
43
|
-
#api;
|
|
44
|
-
#runtime;
|
|
45
|
-
#providers;
|
|
46
|
-
#bufferMs;
|
|
47
|
-
constructor(params){
|
|
48
|
-
this.#api = params.api;
|
|
49
|
-
this.#runtime = params.runtime;
|
|
50
|
-
this.#providers = params.providers;
|
|
51
|
-
this.#bufferMs = (params.bufferSeconds ?? DEFAULT_BUFFER_SECONDS) * 1000;
|
|
52
|
-
}
|
|
53
|
-
async get(providerName, ownerDID) {
|
|
54
|
-
const raw = await this.#api.getCredential(providerName, ownerDID);
|
|
55
|
-
if (raw == null) return null;
|
|
56
|
-
const credential = deserializeCredential(raw);
|
|
57
|
-
if (this.#isExpiringSoon(credential) && credential.refreshToken != null) {
|
|
58
|
-
const refreshed = await this.#refresh(providerName, ownerDID, credential, credential.refreshToken);
|
|
59
|
-
return refreshed ?? credential;
|
|
60
|
-
}
|
|
61
|
-
return credential;
|
|
62
|
-
}
|
|
63
|
-
async set(params) {
|
|
64
|
-
const { providerName, ownerDID, credential, ownerWrappableDID, controllerWrappableDID } = params;
|
|
65
|
-
const serialized = serializeCredential(credential);
|
|
66
|
-
// Update when one exists, mint only when it does not. Dispatching on
|
|
67
|
-
// presence rather than on the caller keeps the refresh path unable to mint
|
|
68
|
-
// even when it does hold a wrappable DID.
|
|
69
|
-
if (await this.#api.getCredential(providerName, ownerDID) != null) {
|
|
70
|
-
await this.#api.setCredential(providerName, ownerDID, serialized);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (ownerWrappableDID == null) {
|
|
74
|
-
throw new Error(`Cannot create a connector credential for "${providerName}" without the owner's wrappable DID`);
|
|
75
|
-
}
|
|
76
|
-
await this.#api.createCredential({
|
|
77
|
-
providerName,
|
|
78
|
-
ownerDID,
|
|
79
|
-
ownerWrappableDID,
|
|
80
|
-
controllerWrappableDID,
|
|
81
|
-
credential: serialized
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
async delete(providerName, ownerDID) {
|
|
85
|
-
await this.#api.deleteCredential(providerName, ownerDID);
|
|
86
|
-
}
|
|
87
|
-
#isExpiringSoon(credential) {
|
|
88
|
-
if (credential.expiresAt == null) return false;
|
|
89
|
-
return credential.expiresAt.getTime() - Date.now() < this.#bufferMs;
|
|
90
|
-
}
|
|
91
|
-
async #refresh(providerName, ownerDID, credential, refreshToken) {
|
|
92
|
-
const provider = this.#providers.find((p)=>p.name === providerName);
|
|
93
|
-
if (provider == null || provider.clientID == null || provider.clientSecret == null) {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
const response = await this.#runtime.fetch(provider.tokenEndpoint, {
|
|
98
|
-
method: 'POST',
|
|
99
|
-
headers: {
|
|
100
|
-
'Content-Type': 'application/x-www-form-urlencoded'
|
|
101
|
-
},
|
|
102
|
-
body: new URLSearchParams({
|
|
103
|
-
grant_type: 'refresh_token',
|
|
104
|
-
refresh_token: refreshToken,
|
|
105
|
-
client_id: provider.clientID,
|
|
106
|
-
client_secret: provider.clientSecret
|
|
107
|
-
})
|
|
108
|
-
});
|
|
109
|
-
if (!response.ok) return null;
|
|
110
|
-
const tokenData = await response.json();
|
|
111
|
-
const refreshed = {
|
|
112
|
-
accessToken: tokenData.access_token,
|
|
113
|
-
refreshToken: tokenData.refresh_token ?? credential.refreshToken,
|
|
114
|
-
expiresAt: tokenData.expires_in != null ? new Date(Date.now() + tokenData.expires_in * 1000) : undefined,
|
|
115
|
-
scopes: tokenData.scope?.split(' ') ?? credential.scopes,
|
|
116
|
-
accountLabel: credential.accountLabel,
|
|
117
|
-
metadata: credential.metadata
|
|
118
|
-
};
|
|
119
|
-
await this.set({
|
|
120
|
-
providerName,
|
|
121
|
-
ownerDID,
|
|
122
|
-
credential: refreshed
|
|
123
|
-
});
|
|
124
|
-
return refreshed;
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
1
|
+
// The DB-backed credential provider moved to @kubun/credential; re-exported here
|
|
2
|
+
// so existing importers of `../src/credential.js` and the package index keep
|
|
3
|
+
// resolving it.
|
|
4
|
+
export { DBCredentialProvider } from '@kubun/credential';
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { ConnectorDefinition,
|
|
1
|
+
import type { ConnectorDefinition, SyncBoundary } from '@kubun/connector';
|
|
2
|
+
import type { OAuthProviderDefinition } from '@kubun/credential-types';
|
|
2
3
|
import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
|
|
3
|
-
import
|
|
4
|
+
import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
|
|
5
|
+
import { type ConnectorAPI, type CredentialAPI, type SetSyncStateParams, type SyncStateData } from './api.js';
|
|
4
6
|
import { DBCredentialProvider, type DBCredentialProviderParams } from './credential.js';
|
|
5
7
|
export { type ExecuteActionDeps, type ExecuteActionParams, type ExecuteActionResult, executeAction, } from './action.js';
|
|
6
8
|
export { type ConnectorActivatedEvent, type ConnectorDeactivatedEvent, ConnectorManager, type ConnectorManagerEvents, type ConnectorManagerParams, } from './manager.js';
|
|
@@ -13,13 +15,14 @@ export { type OrchestrateSyncParams, type OrchestrateSyncResult, orchestrateSync
|
|
|
13
15
|
export { EntityProcessor, type EntityProcessorParams, type MutateDocuments, type ProcessBatchResult, type SignedDocumentWriter, } from './sync/processor.js';
|
|
14
16
|
export { DBSyncStateStore } from './sync/state.js';
|
|
15
17
|
export { CONNECTOR_SYNC_CONCURRENCY, CONNECTOR_SYNC_WORKFLOW, type ConnectorSyncWorkflowDefinition, type ConnectorSyncWorkflowParams, createConnectorSyncWorkflow, } from './sync/workflow.js';
|
|
16
|
-
export type { ConnectorAPI, SetSyncStateParams, SyncStateData };
|
|
18
|
+
export type { ConnectorAPI, CredentialAPI, SetSyncStateParams, SyncStateData };
|
|
17
19
|
export { DBCredentialProvider, type DBCredentialProviderParams };
|
|
18
20
|
export type ConnectorPluginOptions = {
|
|
19
21
|
connectors: Array<ConnectorDefinition>;
|
|
20
22
|
providers?: Array<OAuthProviderDefinition>;
|
|
21
23
|
defaults?: {
|
|
22
24
|
boundary?: SyncBoundary;
|
|
25
|
+
periodicSyncPolicy?: AdaptivePolicy;
|
|
23
26
|
};
|
|
24
27
|
};
|
|
25
28
|
export declare function createConnectorPlugin(options: ConnectorPluginOptions): (params: PluginFactoryParams) => KubunPlugin;
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
|
-
import { createConnectorAPI } from './api.js';
|
|
6
|
+
import { createConnectorAPI, createCredentialAPI } from './api.js';
|
|
7
7
|
import { DBCredentialProvider } from './credential.js';
|
|
8
8
|
import { ConnectorManager } from './manager.js';
|
|
9
9
|
import { OAuthService } from './oauth.js';
|
|
@@ -26,13 +26,60 @@ export { EntityProcessor } from './sync/processor.js';
|
|
|
26
26
|
export { DBSyncStateStore } from './sync/state.js';
|
|
27
27
|
export { CONNECTOR_SYNC_CONCURRENCY, CONNECTOR_SYNC_WORKFLOW, createConnectorSyncWorkflow } from './sync/workflow.js';
|
|
28
28
|
export { DBCredentialProvider };
|
|
29
|
+
// 5m after a productive run; idle 15m→6h, error 1m→1h, offline 30s→15m.
|
|
30
|
+
const DEFAULT_PERIODIC_SYNC_POLICY = {
|
|
31
|
+
changed: 300000,
|
|
32
|
+
idle: {
|
|
33
|
+
base: 900000,
|
|
34
|
+
max: 21600000
|
|
35
|
+
},
|
|
36
|
+
error: {
|
|
37
|
+
base: 60000,
|
|
38
|
+
max: 3600000
|
|
39
|
+
},
|
|
40
|
+
offline: {
|
|
41
|
+
base: 30000,
|
|
42
|
+
max: 900000
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
// 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.
|
|
48
|
+
function withProvenance(provider, api) {
|
|
49
|
+
return {
|
|
50
|
+
get: (providerName, ownerDID)=>provider.get(providerName, ownerDID),
|
|
51
|
+
set: (setParams)=>provider.set(setParams),
|
|
52
|
+
delete: (providerName, ownerDID)=>provider.delete(providerName, ownerDID),
|
|
53
|
+
getCredentialProvenance: (providerName, ownerDID)=>api.getCredentialProvenance(providerName, ownerDID)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
29
56
|
// ---- Connector state resolution ----
|
|
30
|
-
|
|
57
|
+
// Map a workflow instance status to the connector display status. A run in
|
|
58
|
+
// progress reads SYNCING, a failed terminal ERROR; every other terminal
|
|
59
|
+
// (completed/cancelled) is IDLE.
|
|
60
|
+
function displayStatusFor(status) {
|
|
61
|
+
if (status === 'pending' || status === 'running') return 'SYNCING';
|
|
62
|
+
if (status === 'failed') return 'ERROR';
|
|
63
|
+
return 'IDLE';
|
|
64
|
+
}
|
|
65
|
+
async function resolveConnectorState({ connectorAPI, registry, credentialProvider, getWorkflowAPI, viewerDID, connectorName }) {
|
|
31
66
|
const connector = registry.get(connectorName);
|
|
32
67
|
if (connector == null) {
|
|
33
68
|
throw new Error(`Connector "${connectorName}" not found`);
|
|
34
69
|
}
|
|
35
70
|
const syncState = await connectorAPI.getSyncState(connectorName, viewerDID);
|
|
71
|
+
// The durable, lease-fenced truth: the workflow instance status. Preferred over
|
|
72
|
+
// the hand-written syncState.status row, which a zombie handler can transiently
|
|
73
|
+
// regress after crash-recovery. Falls back to that row only with no workflow
|
|
74
|
+
// plugin (local-only sync) or no instance yet.
|
|
75
|
+
let workflowStatus = null;
|
|
76
|
+
const workflowAPI = await getWorkflowAPI();
|
|
77
|
+
if (workflowAPI != null) {
|
|
78
|
+
const instance = await workflowAPI.getCurrentInstance(CONNECTOR_SYNC_WORKFLOW, `${connectorName}:${viewerDID}`);
|
|
79
|
+
if (instance != null) {
|
|
80
|
+
workflowStatus = displayStatusFor(instance.status);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
36
83
|
let authenticated = false;
|
|
37
84
|
let hasWriteAccess = false;
|
|
38
85
|
let authExpiresAt = null;
|
|
@@ -42,7 +89,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
|
|
|
42
89
|
const credential = await credentialProvider.get(connector.auth.provider, viewerDID);
|
|
43
90
|
if (credential != null) {
|
|
44
91
|
authenticated = true;
|
|
45
|
-
const provenance = await
|
|
92
|
+
const provenance = await credentialProvider.getCredentialProvenance(connector.auth.provider, viewerDID);
|
|
46
93
|
credentialUpdatedAt = provenance?.updatedAt ?? null;
|
|
47
94
|
credentialWriterDID = provenance?.writerDID ?? null;
|
|
48
95
|
if (credential.expiresAt != null) {
|
|
@@ -59,7 +106,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
|
|
|
59
106
|
if (syncState == null) {
|
|
60
107
|
return {
|
|
61
108
|
name: connectorName,
|
|
62
|
-
status: 'IDLE',
|
|
109
|
+
status: workflowStatus ?? 'IDLE',
|
|
63
110
|
authenticated,
|
|
64
111
|
hasWriteAccess,
|
|
65
112
|
authExpiresAt,
|
|
@@ -72,7 +119,7 @@ async function resolveConnectorState(connectorAPI, registry, credentialProvider,
|
|
|
72
119
|
}
|
|
73
120
|
return {
|
|
74
121
|
name: connectorName,
|
|
75
|
-
status: syncState.status === 'idle' ? 'IDLE' : syncState.status === 'syncing' ? 'SYNCING' : 'ERROR',
|
|
122
|
+
status: workflowStatus ?? (syncState.status === 'idle' ? 'IDLE' : syncState.status === 'syncing' ? 'SYNCING' : 'ERROR'),
|
|
76
123
|
authenticated,
|
|
77
124
|
hasWriteAccess,
|
|
78
125
|
authExpiresAt,
|
|
@@ -123,39 +170,98 @@ export function createConnectorPlugin(options) {
|
|
|
123
170
|
// One manager per StoreProvider: the request-scoped provider is the
|
|
124
171
|
// mutation's transaction, and a manager built over the base provider would
|
|
125
172
|
// read outside it. The key cache is per-manager, which is the same scope the
|
|
126
|
-
// content key should have.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
173
|
+
// content key should have. Parameterised by the signing identity and HLC so a
|
|
174
|
+
// manager over an external credential graph stamps with that graph's writer,
|
|
175
|
+
// not the engine's.
|
|
176
|
+
const credentialManagerFor = (mgrIdentity, mgrHLC)=>{
|
|
177
|
+
const managers = new WeakMap();
|
|
178
|
+
return (stores)=>{
|
|
179
|
+
let manager = managers.get(stores);
|
|
180
|
+
if (manager == null) {
|
|
181
|
+
manager = getCredentialStore(stores).then((store)=>createCredentialManager({
|
|
182
|
+
store,
|
|
183
|
+
identity: mgrIdentity,
|
|
184
|
+
runtime: params.runtime,
|
|
185
|
+
hlc: mgrHLC
|
|
186
|
+
}));
|
|
187
|
+
managers.set(stores, manager);
|
|
188
|
+
}
|
|
189
|
+
return manager;
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
// The engine-scoped builder: still the manager for the engine's own request
|
|
193
|
+
// transaction on the no-backend path.
|
|
194
|
+
const getCredentialManager = credentialManagerFor(identity, params.hlc);
|
|
141
195
|
// Controller resolvers for `did:kokuin:` wrapping recipients, scoped to the
|
|
142
|
-
//
|
|
143
|
-
// than a second connection.
|
|
196
|
+
// resolved provider so a resolve reads that provider's controller store
|
|
197
|
+
// rather than a second connection.
|
|
144
198
|
const getControllerMethods = (stores)=>[
|
|
145
199
|
params.controllerResolverFor(stores)
|
|
146
200
|
];
|
|
201
|
+
// Mode is decided by provider presence, resolved once. A registered
|
|
202
|
+
// `credential-provider` (service mode) routes every credential op through the
|
|
203
|
+
// remote credential service; its absence (standalone) builds the local stack
|
|
204
|
+
// over the engine's own db, exactly as before. The registry gate closes after
|
|
205
|
+
// construction, so this promise settles lazily — memoized by capture.
|
|
206
|
+
const credentialServiceProviderPromise = params.engine.getProvider('credential-provider');
|
|
207
|
+
// The standalone credential stack over the engine's own db, built lazily and
|
|
208
|
+
// once — only ever touched when no service provider is registered. `stores`
|
|
209
|
+
// defaults to params.db for the background (workflow/orchestrate) paths, which
|
|
210
|
+
// run outside any request transaction. A request path passes its own tx.
|
|
211
|
+
let standaloneStack;
|
|
212
|
+
const getStandaloneStack = (stores = params.db)=>{
|
|
213
|
+
// The background stack is cached on params.db; a per-request tx builds a
|
|
214
|
+
// fresh stack so its reads and writes join that transaction.
|
|
215
|
+
if (stores === params.db && standaloneStack != null) return standaloneStack;
|
|
216
|
+
const api = createCredentialAPI({
|
|
217
|
+
stores,
|
|
218
|
+
logger,
|
|
219
|
+
getCredentialManager,
|
|
220
|
+
serverWrappableDID,
|
|
221
|
+
getControllerMethods
|
|
222
|
+
});
|
|
223
|
+
const stack = {
|
|
224
|
+
api,
|
|
225
|
+
provider: new DBCredentialProvider({
|
|
226
|
+
api,
|
|
227
|
+
runtime: params.runtime,
|
|
228
|
+
providers
|
|
229
|
+
})
|
|
230
|
+
};
|
|
231
|
+
if (stores === params.db) standaloneStack = stack;
|
|
232
|
+
return stack;
|
|
233
|
+
};
|
|
234
|
+
// Resolve the unified credential surface for a given store provider. Service
|
|
235
|
+
// mode reuses the registry provider as-is (plugin-scoped, not the request tx);
|
|
236
|
+
// standalone wraps the local stack, exposing provenance off its credential API.
|
|
237
|
+
const resolveCredentialSurface = async (stores)=>{
|
|
238
|
+
const serviceProvider = await credentialServiceProviderPromise;
|
|
239
|
+
if (serviceProvider != null) {
|
|
240
|
+
return {
|
|
241
|
+
provider: serviceProvider
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const { api, provider } = getStandaloneStack(stores);
|
|
245
|
+
return {
|
|
246
|
+
provider: withProvenance(provider, api)
|
|
247
|
+
};
|
|
248
|
+
};
|
|
147
249
|
const connectorAPI = createConnectorAPI({
|
|
148
250
|
stores: params.db,
|
|
149
|
-
logger
|
|
150
|
-
getCredentialManager,
|
|
151
|
-
serverWrappableDID,
|
|
152
|
-
getControllerMethods
|
|
153
|
-
});
|
|
154
|
-
const credentialProvider = new DBCredentialProvider({
|
|
155
|
-
api: connectorAPI,
|
|
156
|
-
runtime: params.runtime,
|
|
157
|
-
providers
|
|
251
|
+
logger
|
|
158
252
|
});
|
|
253
|
+
// The plugin-scope credential surface for the background paths (workflow,
|
|
254
|
+
// fallback orchestrate, OAuth callback fallback). It switches on mode per call
|
|
255
|
+
// so a service provider registered after construction is still honoured.
|
|
256
|
+
const backgroundCredentialProvider = {
|
|
257
|
+
get: async (providerName, ownerDID)=>(await resolveCredentialSurface(params.db)).provider.get(providerName, ownerDID),
|
|
258
|
+
set: async (setParams)=>{
|
|
259
|
+
await (await resolveCredentialSurface(params.db)).provider.set(setParams);
|
|
260
|
+
},
|
|
261
|
+
delete: async (providerName, ownerDID)=>{
|
|
262
|
+
await (await resolveCredentialSurface(params.db)).provider.delete(providerName, ownerDID);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
159
265
|
const stateStore = new DBSyncStateStore({
|
|
160
266
|
api: connectorAPI
|
|
161
267
|
});
|
|
@@ -168,7 +274,7 @@ export function createConnectorPlugin(options) {
|
|
|
168
274
|
runtime: params.runtime,
|
|
169
275
|
providers,
|
|
170
276
|
registry,
|
|
171
|
-
credentialProvider,
|
|
277
|
+
credentialProvider: backgroundCredentialProvider,
|
|
172
278
|
connectorAPI
|
|
173
279
|
});
|
|
174
280
|
// The primary sync path: enqueue a durable `connector-sync` workflow whose
|
|
@@ -176,7 +282,7 @@ export function createConnectorPlugin(options) {
|
|
|
176
282
|
// registered lazily the first time the workflow API resolves.
|
|
177
283
|
const connectorSyncWorkflow = createConnectorSyncWorkflow({
|
|
178
284
|
registry,
|
|
179
|
-
credentialProvider,
|
|
285
|
+
credentialProvider: backgroundCredentialProvider,
|
|
180
286
|
stateStore,
|
|
181
287
|
syncEventEmitter: manager,
|
|
182
288
|
stores: params.db,
|
|
@@ -209,28 +315,60 @@ export function createConnectorPlugin(options) {
|
|
|
209
315
|
api: connectorAPI,
|
|
210
316
|
createContextFactory: ()=>{
|
|
211
317
|
return (ctx, stores)=>{
|
|
212
|
-
// Per-request connector API
|
|
213
|
-
//
|
|
318
|
+
// Per-request connector API backed by the request's StoreProvider
|
|
319
|
+
// (transactional during mutations). Sync-state stays Space-local, so
|
|
320
|
+
// this always rebuilds over the request tx.
|
|
214
321
|
const requestAPI = createConnectorAPI({
|
|
215
322
|
stores,
|
|
216
|
-
logger
|
|
217
|
-
getCredentialManager,
|
|
218
|
-
serverWrappableDID,
|
|
219
|
-
getControllerMethods
|
|
220
|
-
});
|
|
221
|
-
const requestCredentialProvider = new DBCredentialProvider({
|
|
222
|
-
api: requestAPI,
|
|
223
|
-
runtime: params.runtime,
|
|
224
|
-
providers
|
|
323
|
+
logger
|
|
225
324
|
});
|
|
325
|
+
// Resolve the request's credential surface once. Service mode reuses the
|
|
326
|
+
// registry provider (not the request tx); standalone rebuilds the local
|
|
327
|
+
// stack over the request's transactional provider.
|
|
328
|
+
let surfacePromise;
|
|
329
|
+
const getSurface = ()=>{
|
|
330
|
+
if (surfacePromise == null) surfacePromise = resolveCredentialSurface(stores);
|
|
331
|
+
return surfacePromise;
|
|
332
|
+
};
|
|
333
|
+
// A base provider that defers to the resolved surface, so synchronous
|
|
334
|
+
// consumers (write-grants, executeAction) get one object while the
|
|
335
|
+
// mode resolves lazily behind it.
|
|
336
|
+
const requestProvider = {
|
|
337
|
+
get: async (providerName, ownerDID)=>(await getSurface()).provider.get(providerName, ownerDID),
|
|
338
|
+
set: async (setParams)=>{
|
|
339
|
+
await (await getSurface()).provider.set(setParams);
|
|
340
|
+
},
|
|
341
|
+
delete: async (providerName, ownerDID)=>{
|
|
342
|
+
await (await getSurface()).provider.delete(providerName, ownerDID);
|
|
343
|
+
},
|
|
344
|
+
getCredentialProvenance: async (providerName, ownerDID)=>(await getSurface()).provider.getCredentialProvenance(providerName, ownerDID)
|
|
345
|
+
};
|
|
226
346
|
return {
|
|
227
|
-
getState: (name)=>resolveConnectorState(
|
|
347
|
+
getState: (name)=>resolveConnectorState({
|
|
348
|
+
connectorAPI: requestAPI,
|
|
349
|
+
registry,
|
|
350
|
+
credentialProvider: requestProvider,
|
|
351
|
+
getWorkflowAPI,
|
|
352
|
+
viewerDID: ctx.viewerDID,
|
|
353
|
+
connectorName: name
|
|
354
|
+
}),
|
|
228
355
|
getStates: ()=>{
|
|
229
356
|
const names = registry.list();
|
|
230
|
-
return Promise.all(names.map((name)=>resolveConnectorState(
|
|
357
|
+
return Promise.all(names.map((name)=>resolveConnectorState({
|
|
358
|
+
connectorAPI: requestAPI,
|
|
359
|
+
registry,
|
|
360
|
+
credentialProvider: requestProvider,
|
|
361
|
+
getWorkflowAPI,
|
|
362
|
+
viewerDID: ctx.viewerDID,
|
|
363
|
+
connectorName: name
|
|
364
|
+
})));
|
|
231
365
|
},
|
|
232
|
-
|
|
233
|
-
|
|
366
|
+
// The owner pre-flight resolves against the engine's OWN controller
|
|
367
|
+
// methods, bound to the request tx (`stores`), in both modes. Using
|
|
368
|
+
// `params.db` here would open a second connection inside the
|
|
369
|
+
// startConnectorAuth mutation tx and deadlock single-connection SQLite.
|
|
370
|
+
startAuth: async (args)=>oauthService.startAuth(args, ctx.viewerDID, requestAPI, getControllerMethods(stores)),
|
|
371
|
+
completeAuth: (args)=>oauthService.completeAuth(args, requestProvider, requestAPI),
|
|
234
372
|
triggerSync: async (args)=>{
|
|
235
373
|
if (!registry.has(args.connector)) {
|
|
236
374
|
return {
|
|
@@ -264,7 +402,7 @@ export function createConnectorPlugin(options) {
|
|
|
264
402
|
registry,
|
|
265
403
|
syncEventEmitter: manager,
|
|
266
404
|
stateStore,
|
|
267
|
-
credentialProvider,
|
|
405
|
+
credentialProvider: backgroundCredentialProvider,
|
|
268
406
|
ownerDID,
|
|
269
407
|
stores: params.db,
|
|
270
408
|
boundary: options.defaults?.boundary,
|
|
@@ -280,6 +418,45 @@ export function createConnectorPlugin(options) {
|
|
|
280
418
|
};
|
|
281
419
|
},
|
|
282
420
|
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
|
|
424
|
+
// runs interlock. Scheduled fires later write with no viewer, via the
|
|
425
|
+
// 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
|
+
},
|
|
452
|
+
getPeriodicSync: async (connector)=>{
|
|
453
|
+
const workflowAPI = await getWorkflowAPI();
|
|
454
|
+
if (workflowAPI == null) {
|
|
455
|
+
throw new Error('workflow plugin required for periodic sync');
|
|
456
|
+
}
|
|
457
|
+
const subjectKey = `${connector}:${ctx.viewerDID}`;
|
|
458
|
+
return workflowAPI.getPeriodicSync(`${CONNECTOR_SYNC_WORKFLOW}:${subjectKey}`);
|
|
459
|
+
},
|
|
283
460
|
executeAction: async (args, writeDocument)=>{
|
|
284
461
|
try {
|
|
285
462
|
// Bind the blob write to THIS request's transactional provider so
|
|
@@ -295,7 +472,7 @@ export function createConnectorPlugin(options) {
|
|
|
295
472
|
}, {
|
|
296
473
|
stores,
|
|
297
474
|
registry,
|
|
298
|
-
credentialProvider:
|
|
475
|
+
credentialProvider: requestProvider,
|
|
299
476
|
ownerDID: ctx.viewerDID,
|
|
300
477
|
logger,
|
|
301
478
|
writeDocument,
|
|
@@ -327,7 +504,7 @@ export function createConnectorPlugin(options) {
|
|
|
327
504
|
serverDID,
|
|
328
505
|
viewerDID: ctx.viewerDID,
|
|
329
506
|
stores,
|
|
330
|
-
credentialProvider:
|
|
507
|
+
credentialProvider: requestProvider,
|
|
331
508
|
hlc: params.hlc
|
|
332
509
|
})
|
|
333
510
|
};
|
package/lib/oauth.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { MethodRegistry } from '@kokuin/token';
|
|
2
|
+
import type { CredentialProvider, OAuthProviderDefinition } from '@kubun/credential-types';
|
|
2
3
|
import type { Runtime } from '@sozai/runtime';
|
|
3
|
-
import type { ConnectorAPI } from './api.js';
|
|
4
|
+
import type { ConnectorAPI, CredentialAPI } from './api.js';
|
|
4
5
|
import type { ConnectorRegistry } from './registry.js';
|
|
5
6
|
import type { CompleteConnectorAuthInput, CompleteConnectorAuthOutput, StartConnectorAuthInput, StartConnectorAuthOutput } from './schema.js';
|
|
6
7
|
export type OAuthServiceParams = {
|
|
@@ -9,10 +10,11 @@ export type OAuthServiceParams = {
|
|
|
9
10
|
registry: ConnectorRegistry;
|
|
10
11
|
credentialProvider: CredentialProvider;
|
|
11
12
|
connectorAPI: ConnectorAPI;
|
|
13
|
+
credentialAPI?: CredentialAPI;
|
|
12
14
|
};
|
|
13
15
|
export declare class OAuthService {
|
|
14
16
|
#private;
|
|
15
17
|
constructor(params: OAuthServiceParams);
|
|
16
|
-
startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI): Promise<StartConnectorAuthOutput>;
|
|
18
|
+
startAuth(args: StartConnectorAuthInput, ownerDID: string, connectorAPI?: ConnectorAPI, preflightMethods?: MethodRegistry): Promise<StartConnectorAuthOutput>;
|
|
17
19
|
completeAuth(args: CompleteConnectorAuthInput, credentialProvider?: CredentialProvider, connectorAPI?: ConnectorAPI): Promise<CompleteConnectorAuthOutput>;
|
|
18
20
|
}
|
package/lib/oauth.js
CHANGED
|
@@ -14,14 +14,18 @@ export class OAuthService {
|
|
|
14
14
|
#providers;
|
|
15
15
|
#registry;
|
|
16
16
|
#connectorAPI;
|
|
17
|
+
#preflightMethods;
|
|
17
18
|
constructor(params){
|
|
18
19
|
this.#credentialProvider = params.credentialProvider;
|
|
19
20
|
this.#runtime = params.runtime;
|
|
20
21
|
this.#providers = params.providers;
|
|
21
22
|
this.#registry = params.registry;
|
|
22
23
|
this.#connectorAPI = params.connectorAPI;
|
|
24
|
+
this.#preflightMethods = params.credentialAPI?.getControllerMethods();
|
|
23
25
|
}
|
|
24
|
-
async startAuth(args, ownerDID, connectorAPI
|
|
26
|
+
async startAuth(args, ownerDID, connectorAPI, // The controller methods the owner pre-flight resolves against; `undefined`
|
|
27
|
+
// falls back to the constructor-captured methods (the direct unit tests).
|
|
28
|
+
preflightMethods) {
|
|
25
29
|
// Prefer the request-scoped API so the pending-auth write joins the mutation
|
|
26
30
|
// transaction; the base API would deadlock a single-connection SQLite tx.
|
|
27
31
|
const api = connectorAPI ?? this.#connectorAPI;
|
|
@@ -45,7 +49,8 @@ export class OAuthService {
|
|
|
45
49
|
// `did:kokuin:` owner's agreement key resolves only through the controller
|
|
46
50
|
// resolver, and asking without it would fail closed and block a flow the
|
|
47
51
|
// mint would have completed.
|
|
48
|
-
|
|
52
|
+
const methods = preflightMethods ?? this.#preflightMethods;
|
|
53
|
+
if (methods != null && !await canWrapTo(ownerWrappableDID, methods)) {
|
|
49
54
|
throw new Error(`Cannot start OAuth for "${args.provider}": nothing can be encrypted to "${ownerWrappableDID}", so the credential's owner could never read it. Pass \`viewerWrappableDID\` — for a did:peer:4 viewer that is the long form, which carries the agreement key.`);
|
|
50
55
|
}
|
|
51
56
|
// Carried to the mint like the owner's wrappable DID; it becomes a
|
package/lib/schema.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ConnectorSyncEventPayload } from '@kubun/connector';
|
|
2
2
|
import type { SchemaExtension } from '@kubun/engine';
|
|
3
|
+
import type { AdaptivePolicy } from '@kubun/plugin-workflow-api';
|
|
3
4
|
import type { EventEmitter } from '@sozai/event';
|
|
4
5
|
import type { ConnectorRegistry } from './registry.js';
|
|
5
6
|
import type { SignedDocumentWriter } from './sync/processor.js';
|
|
@@ -164,6 +165,9 @@ export type ConnectorQueryContext = {
|
|
|
164
165
|
provider: string;
|
|
165
166
|
}) => Promise<boolean>;
|
|
166
167
|
connectorWriteGrants?: () => Promise<Array<ConnectorWriteGrant>>;
|
|
168
|
+
enablePeriodicSync?: (connector: string, policyOverride?: AdaptivePolicy) => Promise<unknown>;
|
|
169
|
+
disablePeriodicSync?: (connector: string) => Promise<unknown | null>;
|
|
170
|
+
getPeriodicSync?: (connector: string) => Promise<unknown | null>;
|
|
167
171
|
};
|
|
168
172
|
declare module '@kubun/graphql' {
|
|
169
173
|
interface PluginContextMap {
|
|
@@ -172,6 +176,7 @@ declare module '@kubun/graphql' {
|
|
|
172
176
|
}
|
|
173
177
|
export type ConnectorExtensionConfig = {
|
|
174
178
|
connectors?: Array<string>;
|
|
179
|
+
periodicSync?: boolean;
|
|
175
180
|
};
|
|
176
181
|
export declare function createConnectorSchemaExtension(params: {
|
|
177
182
|
registry?: ConnectorRegistry;
|
package/lib/schema.js
CHANGED
|
@@ -177,6 +177,22 @@ extend type Subscription {
|
|
|
177
177
|
connectorSyncEvents(connector: String): ConnectorSyncEvent!
|
|
178
178
|
}
|
|
179
179
|
`);
|
|
180
|
+
// Recurring-sync control. Gated because it references `PeriodicSync`, which is
|
|
181
|
+
// defined by the co-deployed workflow plugin — referencing it on a connector-only
|
|
182
|
+
// graph would fail schema build with an unknown type.
|
|
183
|
+
const periodicSyncEnabled = params.config.periodicSync === true;
|
|
184
|
+
if (periodicSyncEnabled) {
|
|
185
|
+
sdlParts.push(`
|
|
186
|
+
extend type Query {
|
|
187
|
+
connectorPeriodicSync(connector: String!): PeriodicSync
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
extend type Mutation {
|
|
191
|
+
enableConnectorPeriodicSync(connector: String!, policy: JSON): PeriodicSync!
|
|
192
|
+
disableConnectorPeriodicSync(connector: String!): PeriodicSync
|
|
193
|
+
}
|
|
194
|
+
`);
|
|
195
|
+
}
|
|
180
196
|
// Dynamic per-model action types and mutations from registered connectors
|
|
181
197
|
const dynamicMutationLines = [];
|
|
182
198
|
if (registry != null) {
|
|
@@ -284,6 +300,32 @@ input ConnectorUpdate${modelName}Input {
|
|
|
284
300
|
return conn.disconnectProvider(args);
|
|
285
301
|
}
|
|
286
302
|
};
|
|
303
|
+
// Resolvers for the gated recurring-sync fields. Attached only when the SDL
|
|
304
|
+
// above declared them, since the resolver-attach step rejects a resolver with
|
|
305
|
+
// no matching field. The ownerDID is the viewer, captured by the context factory.
|
|
306
|
+
if (periodicSyncEnabled) {
|
|
307
|
+
queryFields.connectorPeriodicSync = (_source, args, context)=>{
|
|
308
|
+
const conn = requireConnector(context);
|
|
309
|
+
if (conn.getPeriodicSync == null) {
|
|
310
|
+
throw new Error('connector.getPeriodicSync is not available in this context');
|
|
311
|
+
}
|
|
312
|
+
return conn.getPeriodicSync(args.connector);
|
|
313
|
+
};
|
|
314
|
+
mutationFields.enableConnectorPeriodicSync = (_source, args, context)=>{
|
|
315
|
+
const conn = requireConnector(context);
|
|
316
|
+
if (conn.enablePeriodicSync == null) {
|
|
317
|
+
throw new Error('connector.enablePeriodicSync is not available in this context');
|
|
318
|
+
}
|
|
319
|
+
return conn.enablePeriodicSync(args.connector, args.policy);
|
|
320
|
+
};
|
|
321
|
+
mutationFields.disableConnectorPeriodicSync = (_source, args, context)=>{
|
|
322
|
+
const conn = requireConnector(context);
|
|
323
|
+
if (conn.disablePeriodicSync == null) {
|
|
324
|
+
throw new Error('connector.disablePeriodicSync is not available in this context');
|
|
325
|
+
}
|
|
326
|
+
return conn.disablePeriodicSync(args.connector);
|
|
327
|
+
};
|
|
328
|
+
}
|
|
287
329
|
// Dynamic per-model action resolvers
|
|
288
330
|
if (registry != null) {
|
|
289
331
|
for (const connector of registry.getAll()){
|
|
@@ -360,6 +402,17 @@ input ConnectorUpdate${modelName}Input {
|
|
|
360
402
|
};
|
|
361
403
|
return {
|
|
362
404
|
sdl,
|
|
405
|
+
// These write to the workflow store, not the graph, and must return the
|
|
406
|
+
// resulting projection synchronously. Running them inside `mutateGraph`'s
|
|
407
|
+
// write transaction would hold the shared single-connection DB across the
|
|
408
|
+
// workflow-store write and deadlock (as `syncConnector` defers its enqueue to
|
|
409
|
+
// `onCommit` for the same reason).
|
|
410
|
+
...periodicSyncEnabled ? {
|
|
411
|
+
nonTransactionalMutationFields: [
|
|
412
|
+
'enableConnectorPeriodicSync',
|
|
413
|
+
'disableConnectorPeriodicSync'
|
|
414
|
+
]
|
|
415
|
+
} : {},
|
|
363
416
|
resolvers: {
|
|
364
417
|
queryFields,
|
|
365
418
|
mutationFields,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ConnectorSyncEventPayload,
|
|
1
|
+
import type { ConnectorSyncEventPayload, SyncBoundary, SyncStateStore } from '@kubun/connector';
|
|
2
|
+
import type { CredentialProvider } from '@kubun/credential-types';
|
|
2
3
|
import type { StoreProvider } from '@kubun/db';
|
|
3
4
|
import type { Logger } from '@kubun/logger';
|
|
4
5
|
import type { ConnectorRegistry } from '../registry.js';
|
package/lib/sync/workflow.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SyncBoundary, SyncStateStore } from '@kubun/connector';
|
|
2
|
+
import type { CredentialProvider } from '@kubun/credential-types';
|
|
2
3
|
import type { StoreProvider } from '@kubun/db';
|
|
3
4
|
import type { Logger } from '@kubun/logger';
|
|
5
|
+
import type { WorkflowOutcome } from '@kubun/plugin-workflow-api';
|
|
4
6
|
import type { ConnectorRegistry } from '../registry.js';
|
|
5
7
|
import type { SyncEventEmitter } from './orchestrate.js';
|
|
6
8
|
import { type MutateDocuments } from './processor.js';
|
|
@@ -22,6 +24,7 @@ type HandlerResult = {
|
|
|
22
24
|
} | {
|
|
23
25
|
status: 'end';
|
|
24
26
|
state: Record<string, unknown>;
|
|
27
|
+
outcome?: WorkflowOutcome;
|
|
25
28
|
};
|
|
26
29
|
type ConnectorSyncHandler = (ctx: HandlerContext) => Promise<HandlerResult>;
|
|
27
30
|
export type ConnectorSyncWorkflowDefinition = {
|
package/lib/sync/workflow.js
CHANGED
|
@@ -45,11 +45,30 @@ import { MAX_LOGGED_ERRORS } from './processor.js';
|
|
|
45
45
|
}
|
|
46
46
|
const providerName = connector.auth.provider !== 'device' ? connector.auth.provider : null;
|
|
47
47
|
const credential = providerName ? await credentialProvider.get(providerName, ownerDID) : null;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
// A missing credential or absent server provider is "can't sync right now",
|
|
49
|
+
// not a failure: end offline (leaving prior sync state untouched) so the
|
|
50
|
+
// adaptive scheduler backs off on the offline branch rather than the error
|
|
51
|
+
// one. A genuine mid-sync throw below still terminates failed (⇒ error).
|
|
52
|
+
if (providerName != null && credential == null || connector.serverProvider == null) {
|
|
53
|
+
const offlineState = {
|
|
54
|
+
connectorName,
|
|
55
|
+
ownerDID,
|
|
56
|
+
full,
|
|
57
|
+
phase: 'initial',
|
|
58
|
+
checkpoint: null,
|
|
59
|
+
baseEntityCount: 0,
|
|
60
|
+
totalProcessed: 0,
|
|
61
|
+
totalFailed: 0,
|
|
62
|
+
batchNumber: 0,
|
|
63
|
+
startTime: Date.now(),
|
|
64
|
+
lastSyncedAt: new Date().toISOString(),
|
|
65
|
+
failedSample: []
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
status: 'end',
|
|
69
|
+
state: offlineState,
|
|
70
|
+
outcome: 'offline'
|
|
71
|
+
};
|
|
53
72
|
}
|
|
54
73
|
const existing = await stateStore.get(connectorName, ownerDID);
|
|
55
74
|
const isIncremental = !full && existing?.checkpoint != null;
|
|
@@ -216,9 +235,13 @@ import { MAX_LOGGED_ERRORS } from './processor.js';
|
|
|
216
235
|
failedSample: state.failedSample
|
|
217
236
|
} : {}
|
|
218
237
|
});
|
|
238
|
+
// Productive iff this run applied at least one entity; the per-run counter
|
|
239
|
+
// starts at 0 in `start` and accrues in `batch`.
|
|
240
|
+
const outcome = state.totalProcessed > 0 ? 'changed' : 'idle';
|
|
219
241
|
return {
|
|
220
242
|
status: 'end',
|
|
221
|
-
state
|
|
243
|
+
state,
|
|
244
|
+
outcome
|
|
222
245
|
};
|
|
223
246
|
} catch (err) {
|
|
224
247
|
await reportError(state.connectorName, state.ownerDID, state.checkpoint, state.lastSyncedAt, state.baseEntityCount + state.totalProcessed, err);
|
package/lib/write-grants.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CredentialProvider } from '@kubun/
|
|
1
|
+
import type { 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';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/plugin-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,23 @@
|
|
|
17
17
|
"@kokuin/capability": "^0.3.0",
|
|
18
18
|
"@kokuin/jwe": "^0.1.0",
|
|
19
19
|
"@kokuin/token": "^0.5.0",
|
|
20
|
+
"@kubun/connector": "^0.14.0",
|
|
21
|
+
"@kubun/credential": "^0.14.0",
|
|
22
|
+
"@kubun/credential-types": "^0.14.0",
|
|
23
|
+
"@kubun/db": "^0.14.0",
|
|
24
|
+
"@kubun/engine": "^0.14.0",
|
|
25
|
+
"@kubun/graphql": "^0.14.0",
|
|
26
|
+
"@kubun/hlc": "^0.14.0",
|
|
27
|
+
"@kubun/id": "^0.14.0",
|
|
28
|
+
"@kubun/logger": "^0.14.0",
|
|
29
|
+
"@kubun/plugin-blob-api": "^0.14.0",
|
|
30
|
+
"@kubun/plugin-workflow-api": "^0.14.0",
|
|
31
|
+
"@kubun/protocol": "^0.14.0",
|
|
32
|
+
"@kubun/service-credential-api": "^0.14.0",
|
|
33
|
+
"@kubun/store-connector": "^0.14.0",
|
|
34
|
+
"@kubun/store-credential": "^0.14.0",
|
|
35
|
+
"@kubun/store-delegation": "^0.14.0",
|
|
36
|
+
"@kubun/store-graph": "^0.14.0",
|
|
20
37
|
"@noble/hashes": "^2.3.0",
|
|
21
38
|
"@sozai/async": "^0.2.1",
|
|
22
39
|
"@sozai/codec": "^0.4.0",
|
|
@@ -25,35 +42,28 @@
|
|
|
25
42
|
"@sozai/runtime": "^0.1.0",
|
|
26
43
|
"graphql": "^16.14.2",
|
|
27
44
|
"graphql-scalars": "^2.0.0",
|
|
28
|
-
"kysely": "^0.29.5"
|
|
29
|
-
"@kubun/connector": "^0.13.1",
|
|
30
|
-
"@kubun/hlc": "^0.13.0",
|
|
31
|
-
"@kubun/db": "^0.13.0",
|
|
32
|
-
"@kubun/engine": "^0.13.1",
|
|
33
|
-
"@kubun/logger": "^0.13.0",
|
|
34
|
-
"@kubun/graphql": "^0.13.1",
|
|
35
|
-
"@kubun/credential": "^0.13.0",
|
|
36
|
-
"@kubun/store-connector": "^0.13.0",
|
|
37
|
-
"@kubun/store-credential": "^0.13.0",
|
|
38
|
-
"@kubun/id": "^0.13.0",
|
|
39
|
-
"@kubun/store-graph": "^0.13.2",
|
|
40
|
-
"@kubun/store-delegation": "^0.13.0",
|
|
41
|
-
"@kubun/protocol": "^0.13.1"
|
|
45
|
+
"kysely": "^0.29.5"
|
|
42
46
|
},
|
|
43
47
|
"devDependencies": {
|
|
48
|
+
"@enkaku/protocol": "^0.21.0",
|
|
49
|
+
"@enkaku/transport": "^0.21.0",
|
|
44
50
|
"@kokuin/controller": "^0.1.0",
|
|
51
|
+
"@kubun/blob-backend": "^0.14.0",
|
|
52
|
+
"@kubun/connector-google-mail": "^0.14.0",
|
|
53
|
+
"@kubun/db-postgres": "^0.14.0",
|
|
54
|
+
"@kubun/models": "^0.14.0",
|
|
55
|
+
"@kubun/plugin-blob": "^0.14.0",
|
|
56
|
+
"@kubun/plugin-credential": "^0.14.0",
|
|
57
|
+
"@kubun/plugin-service-client": "^0.14.0",
|
|
58
|
+
"@kubun/plugin-service-server": "^0.14.0",
|
|
59
|
+
"@kubun/plugin-workflow": "^0.14.0",
|
|
60
|
+
"@kubun/service-controller-log-api": "^0.14.0",
|
|
61
|
+
"@kubun/store-blob": "^0.14.0",
|
|
62
|
+
"@kubun/store-controller": "^0.14.0",
|
|
63
|
+
"@kubun/store-workflow": "^0.14.0",
|
|
64
|
+
"@kubun/test-utils": "^0.13.0",
|
|
45
65
|
"@testcontainers/postgresql": "^12.1.0",
|
|
46
|
-
"get-port": "^7.2.0"
|
|
47
|
-
"@kubun/blob-backend": "^0.13.0",
|
|
48
|
-
"@kubun/db-postgres": "^0.13.0",
|
|
49
|
-
"@kubun/connector-google-mail": "^0.13.0",
|
|
50
|
-
"@kubun/models": "^0.13.0",
|
|
51
|
-
"@kubun/plugin-blob": "^0.13.0",
|
|
52
|
-
"@kubun/plugin-workflow": "^0.13.0",
|
|
53
|
-
"@kubun/store-controller": "^0.13.0",
|
|
54
|
-
"@kubun/store-blob": "^0.13.0",
|
|
55
|
-
"@kubun/store-workflow": "^0.13.0",
|
|
56
|
-
"@kubun/test-utils": "^0.13.0"
|
|
66
|
+
"get-port": "^7.2.0"
|
|
57
67
|
},
|
|
58
68
|
"publishConfig": {
|
|
59
69
|
"access": "public"
|