@agent-native/core 0.154.3 → 0.154.4
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/corpus/README.md +1 -1
- package/corpus/templates/content/actions/_blocks-field-identity.ts +464 -0
- package/corpus/templates/content/actions/_database-utils.ts +11 -3
- package/corpus/templates/content/actions/_property-utils.ts +137 -30
- package/corpus/templates/content/actions/configure-document-property.ts +5 -0
- package/corpus/templates/content/actions/delete-document-property.ts +38 -17
- package/corpus/templates/content/actions/delete-document.ts +3 -0
- package/corpus/templates/content/actions/edit-document.ts +18 -1
- package/corpus/templates/content/actions/export-document.ts +41 -0
- package/corpus/templates/content/actions/remove-database-items.ts +10 -0
- package/corpus/templates/content/actions/set-document-property.ts +62 -2
- package/corpus/templates/content/actions/update-document.ts +24 -0
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +99 -11
- package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +6 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +4 -1
- package/corpus/templates/content/app/i18n-data.ts +20 -0
- package/corpus/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md +6 -0
- package/corpus/templates/content/changelog/2026-08-13-blocks-field-conflicts-now-keep-the-newer-saved-version-visi.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.object.block.md +5 -4
- package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +9 -4
- package/corpus/templates/content/docs/product/encyclopedia.md +4 -4
- package/corpus/templates/content/server/db/schema.ts +61 -0
- package/corpus/templates/content/server/plugins/db.ts +55 -0
- package/corpus/templates/content/shared/api.ts +3 -0
- package/corpus/templates/content/shared/blocks-field-identity.ts +554 -0
- package/corpus/templates/content/shared/document-export.ts +33 -1
- package/dist/extensions/url-safety.d.ts +7 -2
- package/dist/extensions/url-safety.js +27 -12
- package/dist/mcp-client/oauth-client.d.ts +16 -1
- package/dist/mcp-client/oauth-client.js +177 -83
- package/dist/mcp-client/remote-store.js +46 -20
- package/dist/oauth-tokens/index.d.ts +2 -1
- package/dist/oauth-tokens/index.js +2 -1
- package/dist/oauth-tokens/lifecycle.d.ts +87 -0
- package/dist/oauth-tokens/lifecycle.js +434 -0
- package/dist/oauth-tokens/migrations.js +10 -0
- package/dist/oauth-tokens/store.d.ts +22 -0
- package/dist/oauth-tokens/store.js +141 -18
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export interface OAuthCredentialOwner {
|
|
2
|
+
scope: "user" | "org";
|
|
3
|
+
id: string;
|
|
4
|
+
}
|
|
5
|
+
export interface OAuthCredentialIdentity {
|
|
6
|
+
provider: string;
|
|
7
|
+
accountId: string;
|
|
8
|
+
resource: string;
|
|
9
|
+
owner: OAuthCredentialOwner;
|
|
10
|
+
}
|
|
11
|
+
export interface OAuthCredentialTokens {
|
|
12
|
+
access_token: string;
|
|
13
|
+
refresh_token?: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
interface OAuthLifecycleMetadata {
|
|
17
|
+
version: 1;
|
|
18
|
+
provider: string;
|
|
19
|
+
resource: string;
|
|
20
|
+
owner: string;
|
|
21
|
+
reconnectReason?: "refresh_failed";
|
|
22
|
+
}
|
|
23
|
+
export interface OAuthCredential {
|
|
24
|
+
tokens: OAuthCredentialTokens;
|
|
25
|
+
tokenExpiresAt?: number;
|
|
26
|
+
oauthLifecycle?: OAuthLifecycleMetadata;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
interface CredentialSnapshot<T extends OAuthCredential> {
|
|
30
|
+
credential: T;
|
|
31
|
+
revision: number;
|
|
32
|
+
legacyRevision: number;
|
|
33
|
+
}
|
|
34
|
+
export type OAuthCredentialState<T extends OAuthCredential = OAuthCredential> = {
|
|
35
|
+
kind: "missing";
|
|
36
|
+
} | {
|
|
37
|
+
kind: "malformed";
|
|
38
|
+
revision: number;
|
|
39
|
+
legacyRevision: number;
|
|
40
|
+
reason: "structure" | "identity" | "validation";
|
|
41
|
+
} | ({
|
|
42
|
+
kind: "connected" | "expired" | "reconnect_required";
|
|
43
|
+
} & CredentialSnapshot<T>);
|
|
44
|
+
export interface OAuthCredentialAccessResult<T extends OAuthCredential = OAuthCredential> {
|
|
45
|
+
state: OAuthCredentialState<T>;
|
|
46
|
+
accessToken: string | null;
|
|
47
|
+
}
|
|
48
|
+
export interface OAuthRefreshContext<T extends OAuthCredential> {
|
|
49
|
+
identity: OAuthCredentialIdentity;
|
|
50
|
+
credential: T;
|
|
51
|
+
}
|
|
52
|
+
export interface OAuthRevocationResult {
|
|
53
|
+
remote: "succeeded" | "failed" | "unsupported" | "not_attempted";
|
|
54
|
+
local: "deleted" | "missing" | "replaced";
|
|
55
|
+
}
|
|
56
|
+
interface LifecycleDependencies {
|
|
57
|
+
now: () => number;
|
|
58
|
+
sleep: (ms: number) => Promise<void>;
|
|
59
|
+
holderId: () => string;
|
|
60
|
+
}
|
|
61
|
+
export declare function saveOAuthCredential<T extends OAuthCredential>(identity: OAuthCredentialIdentity, credential: T, options?: {
|
|
62
|
+
legacyAccountKey?: boolean;
|
|
63
|
+
}): Promise<void>;
|
|
64
|
+
export declare function readOAuthCredentialState<T extends OAuthCredential = OAuthCredential>(identity: OAuthCredentialIdentity, options?: {
|
|
65
|
+
allowLegacy?: boolean;
|
|
66
|
+
legacyAccountKey?: boolean;
|
|
67
|
+
now?: number;
|
|
68
|
+
validateCredential?: (credential: T) => boolean;
|
|
69
|
+
}): Promise<OAuthCredentialState<T>>;
|
|
70
|
+
export declare function resolveOAuthCredentialAccess<T extends OAuthCredential = OAuthCredential>(identity: OAuthCredentialIdentity, options: {
|
|
71
|
+
refresh: (context: OAuthRefreshContext<T>) => Promise<T>;
|
|
72
|
+
allowLegacy?: boolean;
|
|
73
|
+
legacyAccountKey?: boolean;
|
|
74
|
+
validateCredential?: (credential: T) => boolean;
|
|
75
|
+
expirySkewMs?: number;
|
|
76
|
+
leaseMs?: number;
|
|
77
|
+
waitMs?: number;
|
|
78
|
+
maxWaitMs?: number;
|
|
79
|
+
dependencies?: Partial<LifecycleDependencies>;
|
|
80
|
+
}): Promise<OAuthCredentialAccessResult<T>>;
|
|
81
|
+
export declare function revokeOAuthCredential<T extends OAuthCredential>(identity: OAuthCredentialIdentity, options?: {
|
|
82
|
+
revoke?: (context: OAuthRefreshContext<T>) => Promise<"succeeded" | "unsupported">;
|
|
83
|
+
allowLegacy?: boolean;
|
|
84
|
+
legacyAccountKey?: boolean;
|
|
85
|
+
validateCredential?: (credential: T) => boolean;
|
|
86
|
+
}): Promise<OAuthRevocationResult>;
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mutateSetting } from "../settings/store.js";
|
|
3
|
+
import { deleteOAuthTokensIfRevision, getOAuthTokenSnapshot, getOAuthTokenSnapshotForUserOwner, replaceOAuthTokensIfRevision, saveOAuthTokens, } from "./store.js";
|
|
4
|
+
const DEFAULT_EXPIRY_SKEW_MS = 60_000;
|
|
5
|
+
const DEFAULT_LEASE_MS = 15_000;
|
|
6
|
+
const DEFAULT_WAIT_MS = 50;
|
|
7
|
+
const DEFAULT_MAX_WAIT_MS = 20_000;
|
|
8
|
+
const LIFECYCLE_VERSION = 1;
|
|
9
|
+
const defaultDependencies = {
|
|
10
|
+
now: () => Date.now(),
|
|
11
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
12
|
+
holderId: () => randomUUID(),
|
|
13
|
+
};
|
|
14
|
+
function ownerKey(owner) {
|
|
15
|
+
const id = owner.id.trim();
|
|
16
|
+
if (!id)
|
|
17
|
+
throw new Error("OAuth credential owner is required.");
|
|
18
|
+
return `${owner.scope}:${owner.scope === "user" ? id.toLowerCase() : id}`;
|
|
19
|
+
}
|
|
20
|
+
function legacyOwnerKey(owner) {
|
|
21
|
+
const id = owner.id.trim();
|
|
22
|
+
if (!id)
|
|
23
|
+
throw new Error("OAuth credential owner is required.");
|
|
24
|
+
return `${owner.scope}:${id}`;
|
|
25
|
+
}
|
|
26
|
+
function assertIdentity(identity) {
|
|
27
|
+
if (!identity.provider.trim())
|
|
28
|
+
throw new Error("OAuth provider is required.");
|
|
29
|
+
if (!identity.accountId.trim()) {
|
|
30
|
+
throw new Error("OAuth account id is required.");
|
|
31
|
+
}
|
|
32
|
+
if (!identity.resource.trim())
|
|
33
|
+
throw new Error("OAuth resource is required.");
|
|
34
|
+
ownerKey(identity.owner);
|
|
35
|
+
}
|
|
36
|
+
function lifecycleMetadata(identity, reconnectReason) {
|
|
37
|
+
return {
|
|
38
|
+
version: LIFECYCLE_VERSION,
|
|
39
|
+
provider: identity.provider,
|
|
40
|
+
resource: identity.resource,
|
|
41
|
+
owner: ownerKey(identity.owner),
|
|
42
|
+
...(reconnectReason ? { reconnectReason } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function withLifecycle(identity, credential, reconnectReason) {
|
|
46
|
+
return {
|
|
47
|
+
...credential,
|
|
48
|
+
oauthLifecycle: lifecycleMetadata(identity, reconnectReason),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function metadataMatches(identity, metadata) {
|
|
52
|
+
return Boolean(metadata &&
|
|
53
|
+
metadata.version === LIFECYCLE_VERSION &&
|
|
54
|
+
metadata.provider === identity.provider &&
|
|
55
|
+
metadata.resource === identity.resource &&
|
|
56
|
+
metadata.owner === ownerKey(identity.owner));
|
|
57
|
+
}
|
|
58
|
+
function leaseKey(identity) {
|
|
59
|
+
const digest = createHash("sha256")
|
|
60
|
+
.update(JSON.stringify([
|
|
61
|
+
identity.provider,
|
|
62
|
+
identity.accountId,
|
|
63
|
+
identity.resource,
|
|
64
|
+
ownerKey(identity.owner),
|
|
65
|
+
]))
|
|
66
|
+
.digest("hex");
|
|
67
|
+
return `oauth-refresh-lease:${digest}`;
|
|
68
|
+
}
|
|
69
|
+
function storageAccountId(identity, legacyAccountKey = false) {
|
|
70
|
+
if (legacyAccountKey)
|
|
71
|
+
return identity.accountId;
|
|
72
|
+
const resourceHash = createHash("sha256")
|
|
73
|
+
.update(identity.resource)
|
|
74
|
+
.digest("hex");
|
|
75
|
+
return `${identity.accountId}:resource:${resourceHash}`;
|
|
76
|
+
}
|
|
77
|
+
async function acquireLease(identity, holder, revision, legacyCredential, leaseMs, now) {
|
|
78
|
+
const next = await mutateSetting(leaseKey(identity), (current) => {
|
|
79
|
+
const currentHolder = typeof current?.holder === "string" ? current.holder : "";
|
|
80
|
+
const expiresAt = typeof current?.expiresAt === "number" ? current.expiresAt : 0;
|
|
81
|
+
const currentRevision = typeof current?.revision === "number" ? current.revision : -1;
|
|
82
|
+
if (currentHolder && currentHolder !== holder) {
|
|
83
|
+
if (expiresAt > now ||
|
|
84
|
+
// A crashed holder may already have redeemed this revision's rotating
|
|
85
|
+
// refresh token. Treat an expired same-revision lease as abandoned so
|
|
86
|
+
// the caller reconnects instead of risking a second redemption.
|
|
87
|
+
currentRevision === revision ||
|
|
88
|
+
(currentRevision === -1 && legacyCredential)) {
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { holder, revision, expiresAt: now + leaseMs };
|
|
93
|
+
});
|
|
94
|
+
if (next.holder === holder && next.revision === revision)
|
|
95
|
+
return "acquired";
|
|
96
|
+
if ((next.revision === revision || typeof next.revision !== "number") &&
|
|
97
|
+
typeof next.expiresAt === "number" &&
|
|
98
|
+
next.expiresAt <= now) {
|
|
99
|
+
return "abandoned";
|
|
100
|
+
}
|
|
101
|
+
return "held";
|
|
102
|
+
}
|
|
103
|
+
async function releaseLease(identity, holder) {
|
|
104
|
+
await mutateSetting(leaseKey(identity), (current) => current?.holder === holder ? { holder: "", expiresAt: 0 } : (current ?? {})).catch(() => undefined);
|
|
105
|
+
}
|
|
106
|
+
function startLeaseHeartbeat(identity, holder, revision, legacyCredential, leaseMs, dependencies) {
|
|
107
|
+
let renewal;
|
|
108
|
+
const timer = setInterval(() => {
|
|
109
|
+
if (renewal)
|
|
110
|
+
return;
|
|
111
|
+
renewal = acquireLease(identity, holder, revision, legacyCredential, leaseMs, dependencies.now())
|
|
112
|
+
.then(() => undefined)
|
|
113
|
+
.catch(() => undefined)
|
|
114
|
+
.finally(() => {
|
|
115
|
+
renewal = undefined;
|
|
116
|
+
});
|
|
117
|
+
}, Math.max(1, Math.floor(leaseMs / 3)));
|
|
118
|
+
timer.unref?.();
|
|
119
|
+
return async () => {
|
|
120
|
+
clearInterval(timer);
|
|
121
|
+
await renewal;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export async function saveOAuthCredential(identity, credential, options = {}) {
|
|
125
|
+
assertIdentity(identity);
|
|
126
|
+
await saveOAuthTokens(identity.provider, storageAccountId(identity, options.legacyAccountKey), withLifecycle(identity, credential), ownerKey(identity.owner));
|
|
127
|
+
}
|
|
128
|
+
async function readStoredOAuthCredentialState(identity, options = {}) {
|
|
129
|
+
assertIdentity(identity);
|
|
130
|
+
const accountId = storageAccountId(identity, options.legacyAccountKey);
|
|
131
|
+
const canonicalOwner = ownerKey(identity.owner);
|
|
132
|
+
let storageOwner = canonicalOwner;
|
|
133
|
+
let stored = await getOAuthTokenSnapshot(identity.provider, accountId, storageOwner);
|
|
134
|
+
const legacyOwner = legacyOwnerKey(identity.owner);
|
|
135
|
+
if (!stored && options.allowLegacy && legacyOwner !== canonicalOwner) {
|
|
136
|
+
storageOwner = legacyOwner;
|
|
137
|
+
stored = await getOAuthTokenSnapshot(identity.provider, accountId, storageOwner);
|
|
138
|
+
}
|
|
139
|
+
if (!stored && options.allowLegacy && identity.owner.scope === "user") {
|
|
140
|
+
stored = await getOAuthTokenSnapshotForUserOwner(identity.provider, accountId, canonicalOwner);
|
|
141
|
+
if (stored?.owner)
|
|
142
|
+
storageOwner = stored.owner;
|
|
143
|
+
}
|
|
144
|
+
if (!stored)
|
|
145
|
+
return { kind: "missing" };
|
|
146
|
+
const parsed = stored.tokens;
|
|
147
|
+
if (!parsed.tokens || typeof parsed.tokens.access_token !== "string") {
|
|
148
|
+
return {
|
|
149
|
+
kind: "malformed",
|
|
150
|
+
revision: stored.revision,
|
|
151
|
+
legacyRevision: stored.legacyRevision,
|
|
152
|
+
storageOwner,
|
|
153
|
+
storageVersion: stored.storageVersion,
|
|
154
|
+
reason: "structure",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if ((!options.allowLegacy &&
|
|
158
|
+
!metadataMatches(identity, parsed.oauthLifecycle)) ||
|
|
159
|
+
(parsed.oauthLifecycle && !metadataMatches(identity, parsed.oauthLifecycle))) {
|
|
160
|
+
return {
|
|
161
|
+
kind: "malformed",
|
|
162
|
+
revision: stored.revision,
|
|
163
|
+
legacyRevision: stored.legacyRevision,
|
|
164
|
+
storageOwner,
|
|
165
|
+
storageVersion: stored.storageVersion,
|
|
166
|
+
reason: "identity",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const credential = parsed;
|
|
170
|
+
if (options.validateCredential && !options.validateCredential(credential)) {
|
|
171
|
+
return {
|
|
172
|
+
kind: "malformed",
|
|
173
|
+
revision: stored.revision,
|
|
174
|
+
legacyRevision: stored.legacyRevision,
|
|
175
|
+
storageOwner,
|
|
176
|
+
storageVersion: stored.storageVersion,
|
|
177
|
+
reason: "validation",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (credential.oauthLifecycle?.reconnectReason) {
|
|
181
|
+
return {
|
|
182
|
+
kind: "reconnect_required",
|
|
183
|
+
credential,
|
|
184
|
+
revision: stored.revision,
|
|
185
|
+
legacyRevision: stored.legacyRevision,
|
|
186
|
+
storageOwner,
|
|
187
|
+
storageVersion: stored.storageVersion,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const now = options.now ?? Date.now();
|
|
191
|
+
if (typeof credential.tokenExpiresAt === "number" &&
|
|
192
|
+
credential.tokenExpiresAt <= now) {
|
|
193
|
+
return {
|
|
194
|
+
kind: "expired",
|
|
195
|
+
credential,
|
|
196
|
+
revision: stored.revision,
|
|
197
|
+
legacyRevision: stored.legacyRevision,
|
|
198
|
+
storageOwner,
|
|
199
|
+
storageVersion: stored.storageVersion,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
kind: "connected",
|
|
204
|
+
credential,
|
|
205
|
+
revision: stored.revision,
|
|
206
|
+
legacyRevision: stored.legacyRevision,
|
|
207
|
+
storageOwner,
|
|
208
|
+
storageVersion: stored.storageVersion,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function publicCredentialState(state) {
|
|
212
|
+
if (state.kind === "missing")
|
|
213
|
+
return state;
|
|
214
|
+
if (state.kind === "malformed") {
|
|
215
|
+
return {
|
|
216
|
+
kind: state.kind,
|
|
217
|
+
revision: state.revision,
|
|
218
|
+
legacyRevision: state.legacyRevision,
|
|
219
|
+
reason: state.reason,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
kind: state.kind,
|
|
224
|
+
credential: state.credential,
|
|
225
|
+
revision: state.revision,
|
|
226
|
+
legacyRevision: state.legacyRevision,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export async function readOAuthCredentialState(identity, options = {}) {
|
|
230
|
+
return publicCredentialState(await readStoredOAuthCredentialState(identity, options));
|
|
231
|
+
}
|
|
232
|
+
async function markReconnectRequired(identity, snapshot, legacyAccountKey) {
|
|
233
|
+
await replaceOAuthTokensIfRevision(identity.provider, storageAccountId(identity, legacyAccountKey), snapshot.storageOwner, snapshot.revision, snapshot.legacyRevision, snapshot.storageVersion, withLifecycle(identity, snapshot.credential, "refresh_failed"));
|
|
234
|
+
}
|
|
235
|
+
export async function resolveOAuthCredentialAccess(identity, options) {
|
|
236
|
+
assertIdentity(identity);
|
|
237
|
+
const dependencies = { ...defaultDependencies, ...options.dependencies };
|
|
238
|
+
const expirySkewMs = options.expirySkewMs ?? DEFAULT_EXPIRY_SKEW_MS;
|
|
239
|
+
const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
|
|
240
|
+
const waitMs = options.waitMs ?? DEFAULT_WAIT_MS;
|
|
241
|
+
const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
242
|
+
const startedAt = dependencies.now();
|
|
243
|
+
let state = await readStoredOAuthCredentialState(identity, {
|
|
244
|
+
allowLegacy: options.allowLegacy,
|
|
245
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
246
|
+
now: startedAt,
|
|
247
|
+
validateCredential: options.validateCredential,
|
|
248
|
+
});
|
|
249
|
+
if (state.kind === "connected" &&
|
|
250
|
+
(typeof state.credential.tokenExpiresAt !== "number" ||
|
|
251
|
+
state.credential.tokenExpiresAt - startedAt > expirySkewMs)) {
|
|
252
|
+
return { state, accessToken: state.credential.tokens.access_token };
|
|
253
|
+
}
|
|
254
|
+
if (state.kind === "missing" ||
|
|
255
|
+
state.kind === "malformed" ||
|
|
256
|
+
state.kind === "reconnect_required") {
|
|
257
|
+
return { state, accessToken: null };
|
|
258
|
+
}
|
|
259
|
+
if (!state.credential.tokens.refresh_token) {
|
|
260
|
+
return {
|
|
261
|
+
state,
|
|
262
|
+
accessToken: state.kind === "connected"
|
|
263
|
+
? state.credential.tokens.access_token
|
|
264
|
+
: null,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const baselineRevision = state.revision;
|
|
268
|
+
const holder = dependencies.holderId();
|
|
269
|
+
while (dependencies.now() - startedAt <= maxWaitMs) {
|
|
270
|
+
const lease = await acquireLease(identity, holder, state.revision, !state.credential.oauthLifecycle, leaseMs, dependencies.now());
|
|
271
|
+
if (lease === "abandoned") {
|
|
272
|
+
await markReconnectRequired(identity, state, options.legacyAccountKey === true);
|
|
273
|
+
const reconnect = await readStoredOAuthCredentialState(identity, {
|
|
274
|
+
allowLegacy: options.allowLegacy,
|
|
275
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
276
|
+
now: dependencies.now(),
|
|
277
|
+
validateCredential: options.validateCredential,
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
state: reconnect,
|
|
281
|
+
accessToken: reconnect.kind === "connected"
|
|
282
|
+
? reconnect.credential.tokens.access_token
|
|
283
|
+
: null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (lease === "held") {
|
|
287
|
+
await dependencies.sleep(waitMs);
|
|
288
|
+
state = await readStoredOAuthCredentialState(identity, {
|
|
289
|
+
allowLegacy: options.allowLegacy,
|
|
290
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
291
|
+
now: dependencies.now(),
|
|
292
|
+
validateCredential: options.validateCredential,
|
|
293
|
+
});
|
|
294
|
+
if (state.kind === "connected" &&
|
|
295
|
+
(state.revision !== baselineRevision ||
|
|
296
|
+
typeof state.credential.tokenExpiresAt !== "number" ||
|
|
297
|
+
state.credential.tokenExpiresAt - dependencies.now() > expirySkewMs)) {
|
|
298
|
+
return { state, accessToken: state.credential.tokens.access_token };
|
|
299
|
+
}
|
|
300
|
+
if (state.kind === "missing" ||
|
|
301
|
+
state.kind === "malformed" ||
|
|
302
|
+
state.kind === "reconnect_required") {
|
|
303
|
+
return { state, accessToken: null };
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
state = await readStoredOAuthCredentialState(identity, {
|
|
309
|
+
allowLegacy: options.allowLegacy,
|
|
310
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
311
|
+
now: dependencies.now(),
|
|
312
|
+
validateCredential: options.validateCredential,
|
|
313
|
+
});
|
|
314
|
+
if (state.kind === "missing" ||
|
|
315
|
+
state.kind === "malformed" ||
|
|
316
|
+
state.kind === "reconnect_required") {
|
|
317
|
+
return { state, accessToken: null };
|
|
318
|
+
}
|
|
319
|
+
if (state.kind === "connected" &&
|
|
320
|
+
(typeof state.credential.tokenExpiresAt !== "number" ||
|
|
321
|
+
state.credential.tokenExpiresAt - dependencies.now() > expirySkewMs)) {
|
|
322
|
+
return {
|
|
323
|
+
state,
|
|
324
|
+
accessToken: state.credential.tokens.access_token,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const stopHeartbeat = startLeaseHeartbeat(identity, holder, state.revision, !state.credential.oauthLifecycle, leaseMs, dependencies);
|
|
329
|
+
const refreshed = withLifecycle(identity, await options
|
|
330
|
+
.refresh({ identity, credential: state.credential })
|
|
331
|
+
.finally(stopHeartbeat));
|
|
332
|
+
const stillOwnsLease = await acquireLease(identity, holder, state.revision, !state.credential.oauthLifecycle, leaseMs, dependencies.now());
|
|
333
|
+
if (stillOwnsLease !== "acquired") {
|
|
334
|
+
await dependencies.sleep(waitMs);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const saved = await replaceOAuthTokensIfRevision(identity.provider, storageAccountId(identity, options.legacyAccountKey), state.storageOwner, state.revision, state.legacyRevision, state.storageVersion, refreshed);
|
|
338
|
+
const latest = await readStoredOAuthCredentialState(identity, {
|
|
339
|
+
allowLegacy: options.allowLegacy,
|
|
340
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
341
|
+
now: dependencies.now(),
|
|
342
|
+
validateCredential: options.validateCredential,
|
|
343
|
+
});
|
|
344
|
+
if (!saved && latest.kind === "connected") {
|
|
345
|
+
return {
|
|
346
|
+
state: latest,
|
|
347
|
+
accessToken: latest.credential.tokens.access_token,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
state: latest,
|
|
352
|
+
accessToken: latest.kind === "connected"
|
|
353
|
+
? latest.credential.tokens.access_token
|
|
354
|
+
: null,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
const stillOwnsLease = await acquireLease(identity, holder, state.revision, !state.credential.oauthLifecycle, leaseMs, dependencies.now());
|
|
359
|
+
if (stillOwnsLease !== "acquired") {
|
|
360
|
+
await dependencies.sleep(waitMs);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const latest = await readStoredOAuthCredentialState(identity, {
|
|
364
|
+
allowLegacy: options.allowLegacy,
|
|
365
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
366
|
+
now: dependencies.now(),
|
|
367
|
+
validateCredential: options.validateCredential,
|
|
368
|
+
});
|
|
369
|
+
if (latest.kind === "connected") {
|
|
370
|
+
return {
|
|
371
|
+
state: latest,
|
|
372
|
+
accessToken: latest.credential.tokens.access_token,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
if (latest.kind === "expired") {
|
|
376
|
+
await markReconnectRequired(identity, latest, options.legacyAccountKey === true);
|
|
377
|
+
const reconnect = await readStoredOAuthCredentialState(identity, {
|
|
378
|
+
allowLegacy: options.allowLegacy,
|
|
379
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
380
|
+
now: dependencies.now(),
|
|
381
|
+
validateCredential: options.validateCredential,
|
|
382
|
+
});
|
|
383
|
+
return { state: reconnect, accessToken: null };
|
|
384
|
+
}
|
|
385
|
+
return { state: latest, accessToken: null };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
await releaseLease(identity, holder);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
state = await readStoredOAuthCredentialState(identity, {
|
|
393
|
+
allowLegacy: options.allowLegacy,
|
|
394
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
395
|
+
now: dependencies.now(),
|
|
396
|
+
validateCredential: options.validateCredential,
|
|
397
|
+
});
|
|
398
|
+
return {
|
|
399
|
+
state,
|
|
400
|
+
accessToken: state.kind === "connected" ? state.credential.tokens.access_token : null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
export async function revokeOAuthCredential(identity, options = {}) {
|
|
404
|
+
const state = await readStoredOAuthCredentialState(identity, {
|
|
405
|
+
allowLegacy: options.allowLegacy,
|
|
406
|
+
legacyAccountKey: options.legacyAccountKey,
|
|
407
|
+
validateCredential: options.validateCredential,
|
|
408
|
+
});
|
|
409
|
+
if (state.kind === "missing") {
|
|
410
|
+
return { remote: "not_attempted", local: "missing" };
|
|
411
|
+
}
|
|
412
|
+
let remote = options.revoke
|
|
413
|
+
? "failed"
|
|
414
|
+
: "unsupported";
|
|
415
|
+
if (options.revoke && state.kind !== "malformed") {
|
|
416
|
+
try {
|
|
417
|
+
remote = await options.revoke({
|
|
418
|
+
identity,
|
|
419
|
+
credential: state.credential,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
remote = "failed";
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else if (state.kind === "malformed") {
|
|
427
|
+
remote = "not_attempted";
|
|
428
|
+
}
|
|
429
|
+
if (state.kind === "malformed" && state.reason !== "structure") {
|
|
430
|
+
return { remote, local: "replaced" };
|
|
431
|
+
}
|
|
432
|
+
const deleted = await deleteOAuthTokensIfRevision(identity.provider, storageAccountId(identity, options.legacyAccountKey), state.storageOwner, state.revision, state.legacyRevision, state.storageVersion);
|
|
433
|
+
return { remote, local: deleted ? "deleted" : "replaced" };
|
|
434
|
+
}
|
|
@@ -33,4 +33,14 @@ export const OAUTH_TOKEN_MIGRATIONS = [
|
|
|
33
33
|
name: "oauth-tokens-owner-backfill",
|
|
34
34
|
sql: "UPDATE oauth_tokens SET owner = account_id WHERE owner IS NULL",
|
|
35
35
|
},
|
|
36
|
+
{
|
|
37
|
+
version: 5,
|
|
38
|
+
name: "oauth-tokens-revision-column",
|
|
39
|
+
sql: "ALTER TABLE oauth_tokens ADD COLUMN IF NOT EXISTS revision BIGINT",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
version: 6,
|
|
43
|
+
name: "oauth-tokens-revision-backfill",
|
|
44
|
+
sql: "UPDATE oauth_tokens SET revision = updated_at WHERE revision IS NULL",
|
|
45
|
+
},
|
|
36
46
|
];
|
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
export declare function getOAuthTokens(provider: string, accountId: string, owner?: string): Promise<Record<string, unknown> | null>;
|
|
2
|
+
export interface OAuthTokenSnapshot {
|
|
3
|
+
tokens: Record<string, unknown>;
|
|
4
|
+
owner: string | null;
|
|
5
|
+
revision: number;
|
|
6
|
+
legacyRevision: number;
|
|
7
|
+
storageVersion: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read one credential bundle together with the row revision used by
|
|
11
|
+
* refresh/revocation compare-and-swap writes.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getOAuthTokenSnapshot(provider: string, accountId: string, owner: string): Promise<OAuthTokenSnapshot | null>;
|
|
14
|
+
/** Read a legacy user-owned row without treating organization ids as case-insensitive. */
|
|
15
|
+
export declare function getOAuthTokenSnapshotForUserOwner(provider: string, accountId: string, owner: string): Promise<OAuthTokenSnapshot | null>;
|
|
16
|
+
/**
|
|
17
|
+
* Replace an existing credential bundle only when the caller still owns the
|
|
18
|
+
* revision it read. This prevents a slow refresh/revoke flow from overwriting
|
|
19
|
+
* a newer authorization completed in another process.
|
|
20
|
+
*/
|
|
21
|
+
export declare function replaceOAuthTokensIfRevision(provider: string, accountId: string, owner: string, expectedRevision: number, expectedLegacyRevision: number, expectedStorageVersion: string, tokens: Record<string, unknown>): Promise<boolean>;
|
|
22
|
+
/** Delete only the exact credential revision the caller inspected. */
|
|
23
|
+
export declare function deleteOAuthTokensIfRevision(provider: string, accountId: string, owner: string, expectedRevision: number, expectedLegacyRevision: number, expectedStorageVersion: string): Promise<boolean>;
|
|
2
24
|
/**
|
|
3
25
|
* Thrown when an OAuth save would re-bind an `(provider, account_id)` row
|
|
4
26
|
* to a different owner than already holds it. Callers should catch this and
|