@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.
Files changed (64) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/.agents/skills/content/SKILL.md +2 -2
  3. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +20 -4
  4. package/corpus/templates/content/actions/_blocks-field-identity.ts +464 -0
  5. package/corpus/templates/content/actions/_database-row-mutation.ts +1490 -0
  6. package/corpus/templates/content/actions/_database-utils.ts +55 -3
  7. package/corpus/templates/content/actions/_property-utils.ts +141 -31
  8. package/corpus/templates/content/actions/add-database-item.ts +62 -240
  9. package/corpus/templates/content/actions/configure-document-property.ts +170 -1
  10. package/corpus/templates/content/actions/delete-document-property.ts +56 -17
  11. package/corpus/templates/content/actions/delete-document.ts +3 -0
  12. package/corpus/templates/content/actions/edit-document.ts +18 -1
  13. package/corpus/templates/content/actions/export-document.ts +41 -0
  14. package/corpus/templates/content/actions/remove-database-items.ts +10 -0
  15. package/corpus/templates/content/actions/set-document-property.ts +140 -3
  16. package/corpus/templates/content/actions/update-database-item.ts +62 -0
  17. package/corpus/templates/content/actions/update-document.ts +24 -0
  18. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +43 -796
  19. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +99 -11
  20. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +6 -0
  21. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +10 -8
  22. package/corpus/templates/content/app/hooks/use-content-database.ts +33 -32
  23. package/corpus/templates/content/app/hooks/use-document-properties.ts +4 -1
  24. package/corpus/templates/content/app/i18n-data.ts +20 -0
  25. package/corpus/templates/content/changelog/2026-08-10-database-blocks-fields-now-keep-logical-block-identity-throu.md +6 -0
  26. package/corpus/templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md +6 -0
  27. package/corpus/templates/content/changelog/2026-08-13-blocks-field-conflicts-now-keep-the-newer-saved-version-visi.md +6 -0
  28. package/corpus/templates/content/docs/product/capabilities/content.object.block.md +5 -4
  29. package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +9 -4
  30. package/corpus/templates/content/docs/product/encyclopedia.md +4 -4
  31. package/corpus/templates/content/parity/matrix.md +1 -1
  32. package/corpus/templates/content/parity/matrix.ts +2 -0
  33. package/corpus/templates/content/server/db/schema.ts +100 -3
  34. package/corpus/templates/content/server/plugins/db.ts +85 -0
  35. package/corpus/templates/content/shared/api.ts +78 -2
  36. package/corpus/templates/content/shared/blocks-field-identity.ts +554 -0
  37. package/corpus/templates/content/shared/document-export.ts +33 -1
  38. package/dist/action.d.ts +22 -0
  39. package/dist/action.js +27 -0
  40. package/dist/collab/awareness.d.ts +2 -2
  41. package/dist/collab/routes.d.ts +1 -1
  42. package/dist/extensions/url-safety.d.ts +7 -2
  43. package/dist/extensions/url-safety.js +27 -12
  44. package/dist/index.browser.d.ts +1 -1
  45. package/dist/index.browser.js +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/mcp-client/oauth-client.d.ts +16 -1
  49. package/dist/mcp-client/oauth-client.js +177 -83
  50. package/dist/mcp-client/remote-store.js +46 -20
  51. package/dist/notifications/routes.d.ts +1 -1
  52. package/dist/oauth-tokens/index.d.ts +2 -1
  53. package/dist/oauth-tokens/index.js +2 -1
  54. package/dist/oauth-tokens/lifecycle.d.ts +87 -0
  55. package/dist/oauth-tokens/lifecycle.js +434 -0
  56. package/dist/oauth-tokens/migrations.js +10 -0
  57. package/dist/oauth-tokens/store.d.ts +22 -0
  58. package/dist/oauth-tokens/store.js +141 -18
  59. package/dist/observability/routes.d.ts +2 -2
  60. package/dist/progress/routes.d.ts +1 -1
  61. package/dist/secrets/routes.d.ts +6 -6
  62. package/dist/server/action-routes.js +10 -2
  63. package/dist/server/realtime-token.d.ts +1 -1
  64. 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 { deleteMcpOAuthCredentials, getMcpOAuthAccessToken, saveMcpOAuthCredentials, } from "./oauth-client.js";
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: input.credentials,
165
+ credentials,
150
166
  });
151
167
  const result = await addRemoteServerInternal(scope, scopeId, {
152
168
  name: input.name,
153
- url: input.url,
169
+ url: credentials.serverUrl,
154
170
  description: input.description,
155
171
  oauthSecretKey,
156
172
  });
157
173
  if (!result.ok) {
158
- await deleteMcpOAuthCredentials({
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 deleteMcpOAuthCredentials({
184
+ await revokeMcpOAuthCredentials({
168
185
  key: oauthSecretKey,
169
186
  scope,
170
187
  scopeId,
171
- }).catch(() => { });
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
  /**
@@ -28,6 +28,6 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
28
28
  } | {
29
29
  count?: undefined;
30
30
  updated?: undefined;
31
- error?: undefined;
32
31
  ok: boolean;
32
+ error?: undefined;
33
33
  }>>;
@@ -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
  ];