@agent-native/core 0.154.3 → 0.154.5
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/.agents/skills/content/SKILL.md +2 -2
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +20 -4
- package/corpus/templates/content/actions/_blocks-field-identity.ts +464 -0
- package/corpus/templates/content/actions/_database-row-mutation.ts +1490 -0
- package/corpus/templates/content/actions/_database-utils.ts +55 -3
- package/corpus/templates/content/actions/_property-utils.ts +141 -31
- package/corpus/templates/content/actions/add-database-item.ts +62 -240
- package/corpus/templates/content/actions/configure-document-property.ts +170 -1
- package/corpus/templates/content/actions/delete-document-property.ts +56 -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 +140 -3
- package/corpus/templates/content/actions/update-database-item.ts +62 -0
- package/corpus/templates/content/actions/update-document.ts +24 -0
- package/corpus/templates/content/actions/upsert-database-item-by-key.ts +43 -796
- 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/components/editor/database/DatabaseView.tsx +10 -8
- package/corpus/templates/content/app/hooks/use-content-database.ts +33 -32
- 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-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.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/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +2 -0
- package/corpus/templates/content/server/db/schema.ts +100 -3
- package/corpus/templates/content/server/plugins/db.ts +85 -0
- package/corpus/templates/content/shared/api.ts +78 -2
- package/corpus/templates/content/shared/blocks-field-identity.ts +554 -0
- package/corpus/templates/content/shared/document-export.ts +33 -1
- package/dist/action.d.ts +22 -0
- package/dist/action.js +27 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/extensions/url-safety.d.ts +7 -2
- package/dist/extensions/url-safety.js +27 -12
- package/dist/index.browser.d.ts +1 -1
- package/dist/index.browser.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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/notifications/routes.d.ts +1 -1
- 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 +2 -2
- package/dist/progress/routes.d.ts +1 -1
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/action-routes.js +10 -2
- package/dist/server/realtime-token.d.ts +1 -1
- package/package.json +2 -1
|
@@ -23,7 +23,7 @@ import { createHash } from "node:crypto";
|
|
|
23
23
|
import { writeAppSecret, readAppSecret, deleteAppSecret, } from "../secrets/storage.js";
|
|
24
24
|
import { getOrgSetting, putOrgSetting, deleteOrgSetting, } from "../settings/org-settings.js";
|
|
25
25
|
import { getUserSetting, putUserSetting, deleteUserSetting, } from "../settings/user-settings.js";
|
|
26
|
-
import {
|
|
26
|
+
import { getMcpOAuthAccessToken, revokeMcpOAuthCredentials, saveMcpOAuthCredentials, } from "./oauth-client.js";
|
|
27
27
|
import { validateRemoteUrl } from "./remote-url.js";
|
|
28
28
|
export { validateRemoteUrl } from "./remote-url.js";
|
|
29
29
|
const SETTINGS_KEY = "mcp-servers-remote";
|
|
@@ -140,35 +140,53 @@ export async function addRemoteServer(scope, scopeId, input) {
|
|
|
140
140
|
* stored separately from the settings row and never returned to clients.
|
|
141
141
|
*/
|
|
142
142
|
export async function addOAuthRemoteServer(scope, scopeId, input) {
|
|
143
|
+
const serverUrl = validateRemoteUrl(input.url);
|
|
144
|
+
const credentialResource = validateRemoteUrl(input.credentials.serverUrl);
|
|
145
|
+
if (!serverUrl.ok ||
|
|
146
|
+
!serverUrl.url ||
|
|
147
|
+
!credentialResource.ok ||
|
|
148
|
+
!credentialResource.url ||
|
|
149
|
+
serverUrl.url.toString() !== credentialResource.url.toString()) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
error: "MCP server URL must match the OAuth credential resource URL",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const credentials = {
|
|
156
|
+
...input.credentials,
|
|
157
|
+
serverUrl: credentialResource.url.toString(),
|
|
158
|
+
};
|
|
143
159
|
const oauthSecretKey = `mcp_oauth:${shortId()}`;
|
|
144
160
|
try {
|
|
145
161
|
await saveMcpOAuthCredentials({
|
|
146
162
|
key: oauthSecretKey,
|
|
147
163
|
scope,
|
|
148
164
|
scopeId,
|
|
149
|
-
credentials
|
|
165
|
+
credentials,
|
|
150
166
|
});
|
|
151
167
|
const result = await addRemoteServerInternal(scope, scopeId, {
|
|
152
168
|
name: input.name,
|
|
153
|
-
url:
|
|
169
|
+
url: credentials.serverUrl,
|
|
154
170
|
description: input.description,
|
|
155
171
|
oauthSecretKey,
|
|
156
172
|
});
|
|
157
173
|
if (!result.ok) {
|
|
158
|
-
await
|
|
174
|
+
await revokeMcpOAuthCredentials({
|
|
159
175
|
key: oauthSecretKey,
|
|
160
176
|
scope,
|
|
161
177
|
scopeId,
|
|
162
|
-
|
|
178
|
+
serverUrl: credentials.serverUrl,
|
|
179
|
+
}).catch(() => undefined);
|
|
163
180
|
}
|
|
164
181
|
return result;
|
|
165
182
|
}
|
|
166
183
|
catch (err) {
|
|
167
|
-
await
|
|
184
|
+
await revokeMcpOAuthCredentials({
|
|
168
185
|
key: oauthSecretKey,
|
|
169
186
|
scope,
|
|
170
187
|
scopeId,
|
|
171
|
-
|
|
188
|
+
serverUrl: credentials.serverUrl,
|
|
189
|
+
}).catch(() => undefined);
|
|
172
190
|
return {
|
|
173
191
|
ok: false,
|
|
174
192
|
error: `Failed to save MCP OAuth credentials: ${err?.message ?? err}`,
|
|
@@ -311,6 +329,27 @@ export async function removeRemoteServer(scope, scopeId, id) {
|
|
|
311
329
|
const next = existing.filter((s) => s.id !== id);
|
|
312
330
|
if (next.length === existing.length)
|
|
313
331
|
return false;
|
|
332
|
+
if (removed?.oauthSecretKey) {
|
|
333
|
+
try {
|
|
334
|
+
const result = await revokeMcpOAuthCredentials({
|
|
335
|
+
key: removed.oauthSecretKey,
|
|
336
|
+
scope,
|
|
337
|
+
scopeId,
|
|
338
|
+
serverUrl: removed.url,
|
|
339
|
+
});
|
|
340
|
+
if (result.local === "replaced") {
|
|
341
|
+
console.warn(`[mcp-client] MCP OAuth credentials changed while removing ${removed.name}; the server was kept so the newer connection remains manageable.`);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (result.remote === "failed") {
|
|
345
|
+
console.warn(`[mcp-client] MCP OAuth revocation failed for ${removed.name}; local credentials were removed.`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
// eslint-disable-next-line no-console
|
|
350
|
+
console.warn(`[mcp-client] Failed to delete MCP OAuth credentials ${removed.oauthSecretKey}: ${err?.message ?? err}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
314
353
|
if (next.length === 0) {
|
|
315
354
|
if (scope === "user") {
|
|
316
355
|
await deleteUserSetting(scopeId, SETTINGS_KEY);
|
|
@@ -338,19 +377,6 @@ export async function removeRemoteServer(scope, scopeId, id) {
|
|
|
338
377
|
console.warn(`[mcp-client] Failed to delete MCP header secret ${removed.headerSecretKey}: ${err?.message ?? err}`);
|
|
339
378
|
}
|
|
340
379
|
}
|
|
341
|
-
if (removed?.oauthSecretKey) {
|
|
342
|
-
try {
|
|
343
|
-
await deleteMcpOAuthCredentials({
|
|
344
|
-
key: removed.oauthSecretKey,
|
|
345
|
-
scope,
|
|
346
|
-
scopeId,
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
catch (err) {
|
|
350
|
-
// eslint-disable-next-line no-console
|
|
351
|
-
console.warn(`[mcp-client] Failed to delete MCP OAuth credentials ${removed.oauthSecretKey}: ${err?.message ?? err}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
380
|
return true;
|
|
355
381
|
}
|
|
356
382
|
/**
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export { getOAuthTokens, OAuthAccountOwnedByOtherUserError, saveOAuthTokens, deleteOAuthTokens, listOAuthAccounts, listOAuthAccountsByOwner, hasOAuthTokens, setOAuthDisplayName, } from "./store.js";
|
|
1
|
+
export { getOAuthTokens, getOAuthTokenSnapshot, OAuthAccountOwnedByOtherUserError, saveOAuthTokens, replaceOAuthTokensIfRevision, deleteOAuthTokens, deleteOAuthTokensIfRevision, listOAuthAccounts, listOAuthAccountsByOwner, hasOAuthTokens, setOAuthDisplayName, } from "./store.js";
|
|
2
|
+
export { readOAuthCredentialState, resolveOAuthCredentialAccess, revokeOAuthCredential, saveOAuthCredential, type OAuthCredential, type OAuthCredentialIdentity, type OAuthCredentialOwner, type OAuthCredentialState, type OAuthRevocationResult, } from "./lifecycle.js";
|
|
2
3
|
export { refreshExpiringGoogleTokens, startGoogleTokenRefreshLoop, } from "./google-refresh.js";
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export { getOAuthTokens, OAuthAccountOwnedByOtherUserError, saveOAuthTokens, deleteOAuthTokens, listOAuthAccounts, listOAuthAccountsByOwner, hasOAuthTokens, setOAuthDisplayName, } from "./store.js";
|
|
1
|
+
export { getOAuthTokens, getOAuthTokenSnapshot, OAuthAccountOwnedByOtherUserError, saveOAuthTokens, replaceOAuthTokensIfRevision, deleteOAuthTokens, deleteOAuthTokensIfRevision, listOAuthAccounts, listOAuthAccountsByOwner, hasOAuthTokens, setOAuthDisplayName, } from "./store.js";
|
|
2
|
+
export { readOAuthCredentialState, resolveOAuthCredentialAccess, revokeOAuthCredential, saveOAuthCredential, } from "./lifecycle.js";
|
|
2
3
|
export { refreshExpiringGoogleTokens, startGoogleTokenRefreshLoop, } from "./google-refresh.js";
|
|
@@ -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
|
];
|