diff --git a/.changeset/credential-host-underscores.md b/.changeset/credential-host-underscores.md new file mode 100644 index 0000000000..8808bdac52 --- /dev/null +++ b/.changeset/credential-host-underscores.md @@ -0,0 +1,8 @@ +--- +"apps": minor +"@executor-js/sdk": minor +--- + +Provider `hosts` may name hosts with underscores, such as the Docker container +`n8n_server_1:5678`. This is host protocol 14. Apps on earlier `apps` releases +keep running; an account they use never shows them a host with an underscore. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index db0a1009a5..a5708ccfdf 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,9 @@ # executor +## 2.0.0-beta.35 + +Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image. + ## 2.0.0-beta.34 Nightly beta of the Executor local runtime, CLI, desktop, and self-host Docker image. diff --git a/apps/cli/package.json b/apps/cli/package.json index a4999471b9..402c82a907 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "2.0.0-beta.34", + "version": "2.0.0-beta.35", "private": true, "description": "Run Executor locally and connect your agents to your apps.", "homepage": "https://executor.sh", diff --git a/apps/hosted/cloud/package.json b/apps/hosted/cloud/package.json index c63ae5bcf6..5dfcb7f4a5 100644 --- a/apps/hosted/cloud/package.json +++ b/apps/hosted/cloud/package.json @@ -14,9 +14,9 @@ }, "dependencies": { "@alchemy.run/better-auth": "2.0.0-beta.80", - "@better-auth/core": "1.7.5", - "@better-auth/passkey": "1.7.5", - "@better-auth/sso": "1.7.5", + "@better-auth/core": "1.7.16", + "@better-auth/passkey": "1.7.16", + "@better-auth/sso": "1.7.16", "@cloudflare/worker-bundler": "0.2.4", "@distilled.cloud/cloudflare": "1.0.0-rc.13", "@distilled.cloud/posthog": "1.0.0-rc.13", @@ -43,7 +43,7 @@ "alchemy": "2.0.0-beta.80", "apps": "workspace:*", "autumn-js": "1.2.28", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "fumadb-effect": "workspace:*", "js-yaml": "5.4.3", diff --git a/apps/hosted/cloud/src/contracts/v1-import-run.ts b/apps/hosted/cloud/src/contracts/v1-import-run.ts index e5b954a469..cdb810dc4f 100644 --- a/apps/hosted/cloud/src/contracts/v1-import-run.ts +++ b/apps/hosted/cloud/src/contracts/v1-import-run.ts @@ -206,6 +206,8 @@ const Artifact = Schema.Struct({ id: Schema.String, title: Schema.String, unmappedRoles: Schema.Array(Schema.Struct({ role: Schema.String, problem: Schema.String })), + /** Why it fails, or would: absent when it moves. */ + error: Schema.optionalKey(Schema.String), }); const VaultFailure = Schema.Struct({ id: Schema.String, diff --git a/apps/hosted/cloud/src/implementation/migrations.ts b/apps/hosted/cloud/src/implementation/migrations.ts index 5fee598819..db43beae15 100644 --- a/apps/hosted/cloud/src/implementation/migrations.ts +++ b/apps/hosted/cloud/src/implementation/migrations.ts @@ -144,6 +144,19 @@ export const migrateV1ImportMap = Effect.gen(function* () { yield* sql`create index if not exists private_v1_import_map_org on private_v1_import_map (v1_org_id)`; }).pipe(Effect.mapError(migrationFailed("product"))); +/** + * A failed retry may keep the v2 id an earlier attempt saved. Imported rows still require one. + * The previous check required every non-imported row to have no id, so a failure erased it and + * the next attempt could revive a revoked toolkit copy. Relaxing the check leaves every row the + * running server writes valid: it still records a failure with no id. + */ +export const migrateV1ImportFailedKeepsId = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`alter table private_v1_import_map drop constraint if exists private_v1_import_map_check`; + yield* sql`alter table private_v1_import_map add constraint private_v1_import_map_imported_has_id + check (status <> 'imported' or v2_id is not null)`; +}).pipe(Effect.mapError(migrationFailed("product"))); + /** * Each v1 organization's import state (`notes/v1-import.md`): the durable record v1's freeze, * redirect and forwarding will read, which outlives a Workflow instance's retention. Only an import @@ -302,6 +315,7 @@ export const migrateCloudDatabase = Effect.scoped( "11_v1_import_billing": migrateV1ImportBilling, "12_v1_import_displacement": migrateV1ImportDisplacement, "13_v1_import_into": migrateV1ImportInto, + "14_v1_import_failed_keeps_id": migrateV1ImportFailedKeepsId, }); }); yield* migrateHostedDatabase(setup.options, cloudMigrations).pipe( diff --git a/apps/hosted/cloud/src/implementation/oauth-proxy-guard.ts b/apps/hosted/cloud/src/implementation/oauth-proxy-guard.ts index 66de036fcb..6eb5f250ce 100644 --- a/apps/hosted/cloud/src/implementation/oauth-proxy-guard.ts +++ b/apps/hosted/cloud/src/implementation/oauth-proxy-guard.ts @@ -10,9 +10,13 @@ * `oAuthProxy` so its before hooks run first. * * Recognition of a proxy package matches the plugin's own truthiness test. A - * stricter test would let a package the plugin still accepts skip this guard, and - * anything that decrypts under the proxy secret but cannot be understood is - * refused rather than waved through. + * stricter test would let a package the plugin still accepts skip this guard. + * better-auth 1.7.16 encrypts that package and its state cookie under + * purpose-derived keys, not the raw secret. Decrypting with the raw secret fails, + * and treating that failure as "not a proxy package" lets the plugin redirect to + * whatever target the ciphertext names. A decrypt or parse failure of anything + * other than a normal sign-in state is refused here: a redirect thrown later, in + * the plugin's before hook, never reaches the after hooks. * * The plugin also rewrites the outgoing `Location` on `/callback/:id` from an * unvalidated `callbackURL` parameter. `oauthProxyLocationGuard` re-checks that @@ -21,7 +25,7 @@ */ import type { BetterAuthPlugin } from "better-auth"; import { APIError, createAuthMiddleware } from "better-auth/api"; -import { symmetricDecrypt } from "better-auth/crypto"; +import { derivePurposeKey, symmetricDecrypt } from "better-auth/crypto"; /** Endpoints that only a test stage needs; production must not accept a profile. */ const completionPaths = new Set(["/callback/:id/oauth-proxy", "/oauth-proxy-callback"]); @@ -83,6 +87,15 @@ export const proxyRedirectOrigins = (stateData: unknown): ReadonlyArray return [...origins]; }; +/** + * Production's own sign-in state: `generateRandomString(32)`. It is not ciphertext. + * A proxy package is hex from `symmetricEncrypt` and is far longer. + */ +const regularSignInState = /^[A-Za-z0-9_-]{32}$/u; + +const decryptJson = async (key: string, data: string): Promise => + JSON.parse(await symmetricDecrypt({ key, data })); + const proxiedRedirectOrigins = async ( secret: string, state: unknown, @@ -90,17 +103,20 @@ const proxiedRedirectOrigins = async ( if (typeof state !== "string" || state.length === 0) return undefined; let statePackage: unknown; try { - statePackage = JSON.parse(await symmetricDecrypt({ key: secret, data: state })); + statePackage = await decryptJson(derivePurposeKey(secret, "oauth-proxy-package"), state); } catch { - // Not a proxy package. Production's own sign-ins use the regular callback. - return undefined; + // The plugin uses this same key. A normal sign-in token is not a package, so the + // callback continues. Anything else that will not decrypt is refused: the plugin + // must not be left to follow a target this guard could not read. + if (regularSignInState.test(state)) return undefined; + throw badState("OAuth proxy state cannot be read."); } - if (!isProxyStatePackage(statePackage)) return undefined; + if (!isProxyStatePackage(statePackage)) throw badState("OAuth proxy state cannot be read."); const stateCookie = (statePackage as { stateCookie: unknown }).stateCookie; if (typeof stateCookie !== "string") throw badState("OAuth proxy state cannot be read."); let stateData: unknown; try { - stateData = JSON.parse(await symmetricDecrypt({ key: secret, data: stateCookie })); + stateData = await decryptJson(derivePurposeKey(secret, "oauth-proxy-state"), stateCookie); } catch { throw badState("OAuth proxy state cannot be read."); } diff --git a/apps/hosted/cloud/src/implementation/v1-import/formats.ts b/apps/hosted/cloud/src/implementation/v1-import/formats.ts index 9b754e2096..199e7e10ff 100644 --- a/apps/hosted/cloud/src/implementation/v1-import/formats.ts +++ b/apps/hosted/cloud/src/implementation/v1-import/formats.ts @@ -664,37 +664,50 @@ const restriction = { approve: 1, require_approval: 2, block: 3 } as const; * but only when stricter: a member's `approve` result, including "don't ask again", is dropped, so * the tool asks again (decision 10). Each member's own first match still decides, so a member's * `block` that their own earlier `approve` shadowed in v1 stays shadowed. + * + * The policies are ordered and their actions read once, so deciding each tool only matches them. */ -export const resolvePolicy = ( - toolId: string, - policies: readonly PolicyRow[], - multiMember = false, -): PolicyAction | undefined => { - const sorted = [...policies].sort((a, b) => - a.position < b.position - ? -1 - : a.position > b.position - ? 1 - : a.id < b.id - ? -1 - : a.id > b.id - ? 1 - : 0, - ); - const first = new Map(); - for (const row of sorted) { - const partition = row.owner === "org" ? "org" : `user:${row.subject}`; - if (first.has(partition)) continue; - const action = Schema.decodeUnknownOption(PolicyAction)(row.action); - if (Option.isSome(action) && matchPattern(row.pattern, toolId)) - first.set(partition, { owner: row.owner, action: action.value }); - } - let selected: PolicyAction | undefined; - for (const { owner, action } of first.values()) { - if (multiMember && owner === "user" && action === "approve") continue; - if (selected === undefined || restriction[action] > restriction[selected]) selected = action; - } - return selected; +export const policyResolver = (policies: readonly PolicyRow[], multiMember = false) => { + const ordered = [...policies] + .sort((a, b) => + a.position < b.position + ? -1 + : a.position > b.position + ? 1 + : a.id < b.id + ? -1 + : a.id > b.id + ? 1 + : 0, + ) + .flatMap((row) => { + // A row whose action v1 does not know never matched a tool. + const action = Schema.decodeUnknownOption(PolicyAction)(row.action); + return Option.isSome(action) + ? [ + { + partition: row.owner === "org" ? "org" : `user:${row.subject}`, + owner: row.owner, + pattern: row.pattern, + action: action.value, + }, + ] + : []; + }); + return (toolId: string): PolicyAction | undefined => { + const first = new Map(); + for (const row of ordered) { + if (first.has(row.partition)) continue; + if (matchPattern(row.pattern, toolId)) + first.set(row.partition, { owner: row.owner, action: row.action }); + } + let selected: PolicyAction | undefined; + for (const { owner, action } of first.values()) { + if (multiMember && owner === "user" && action === "approve") continue; + if (selected === undefined || restriction[action] > restriction[selected]) selected = action; + } + return selected; + }; }; /** v1 accepted only these patterns; anything else never matched a tool. */ @@ -767,6 +780,30 @@ const byPosition = (a: ToolkitRuleData, b: ToolkitRuleData) => ? 1 : 0; +/** A toolkit rule that decodes, with its owner partition. */ +interface DecodedToolkitRule { + readonly owner: ToolkitRuleRow["owner"]; + readonly subject: string; + readonly collection: ToolkitRuleRow["collection"]; + readonly rule: ToolkitRuleData; +} + +/** The rules that decode, in their order; v1 skipped the others. */ +const decodeToolkitRules = (rules: readonly ToolkitRuleRow[]): readonly DecodedToolkitRule[] => + rules.flatMap((rule) => { + const decoded = Schema.decodeUnknownOption(ToolkitRuleData)(jsonValue(rule.data)); + return Option.isSome(decoded) + ? [ + { + owner: rule.owner, + subject: rule.subject, + collection: rule.collection, + rule: decoded.value, + }, + ] + : []; + }); + /** * A toolkit and its rules, or undefined when its row does not decode. v1 reads an `approve` policy * ending in `*` with three or four segments as access, unless a connection rule already covers it. @@ -774,16 +811,37 @@ const byPosition = (a: ToolkitRuleData, b: ToolkitRuleData) => export const decodeToolkit = ( row: ToolkitRow, rules: readonly ToolkitRuleRow[], +): V1Toolkit | undefined => + toolkitWith( + row, + decodeToolkitRules( + rules.filter((rule) => rule.owner === row.owner && rule.subject === row.subject), + ), + ); + +/** Every toolkit that decodes, as `decodeToolkit` reads it, decoding each rule once. */ +export const decodeToolkits = ( + rows: readonly ToolkitRow[], + rules: readonly ToolkitRuleRow[], +): readonly V1Toolkit[] => { + const decoded = decodeToolkitRules(rules); + return rows.flatMap((row) => { + const toolkit = toolkitWith(row, decoded); + return toolkit === undefined ? [] : [toolkit]; + }); +}; + +/** A toolkit with the decoded rules of its owner partition and id. */ +const toolkitWith = ( + row: ToolkitRow, + rules: readonly DecodedToolkitRule[], ): V1Toolkit | undefined => { const data = Option.getOrUndefined(Schema.decodeUnknownOption(ToolkitData)(jsonValue(row.data))); if (data === undefined) return undefined; - const own = rules.flatMap((rule) => { - if (rule.owner !== row.owner || rule.subject !== row.subject) return []; - const decoded = Schema.decodeUnknownOption(ToolkitRuleData)(jsonValue(rule.data)); - return Option.isSome(decoded) && decoded.value.toolkitId === data.id - ? [{ collection: rule.collection, rule: decoded.value }] - : []; - }); + const own = rules.filter( + (rule) => + rule.owner === row.owner && rule.subject === row.subject && rule.rule.toolkitId === data.id, + ); const connections = own .filter((entry) => entry.collection === "toolkitConnections") .map((entry) => entry.rule); diff --git a/apps/hosted/cloud/src/implementation/v1-import/items.ts b/apps/hosted/cloud/src/implementation/v1-import/items.ts index 094665eb28..44d7388a79 100644 --- a/apps/hosted/cloud/src/implementation/v1-import/items.ts +++ b/apps/hosted/cloud/src/implementation/v1-import/items.ts @@ -37,6 +37,8 @@ import { import { AccountId, AppId, + appSlug, + appSlugFits, OAuthGrantImport, OAuthRegistration, scopeGeneratedPackage, @@ -60,7 +62,7 @@ import { credentialLike, itemOwner, mcpToolName, - resolvePolicy, + policyResolver, secretPrefix, tokenEndpointAuthMethod, toolkitAction, @@ -435,21 +437,94 @@ export const connectionSnapshot = >( ), }); +/** + * An app name the importer makes, in parts: fixed text, and text it may shorten (`{ cut }`). The + * fixed text tells apps apart whose other text is the same, such as a connection's number. + */ +export type AppNaming = readonly (string | { readonly cut: string })[]; + +/** Why the importer made no app: every name it tries (`appNames`) is taken in the organization. */ +export const namesTaken = "Every name for this app is taken in the organization."; + +/** The names the importer tries for one app before it gives up (`namesTaken`). */ +const nameAttempts = 5; + +/** The longest app name (`AppName`). */ +const nameLength = 120; + +/** Spaces and punctuation that would dangle before a shortened text's "…". */ +const dangling = /[\s\p{Ps}\p{Pd}\p{Po}]+$/u; + +/** `text` shortened with "…", longest first: at each word, then at each character. */ +const shortenings = (text: string) => { + const characters = Array.from(text); + const words = [...text.matchAll(/\s+/gu)].map((match) => text.slice(0, match.index)).reverse(); + const cuts = characters.map((_, end) => characters.slice(0, end).join("")).reverse(); + return [...words, ...cuts].map((kept) => `${kept.replace(dangling, "")}…`); +}; + +/** + * The name the importer gives an app on its `attempt`: `naming`'s parts, then on a retry + * `(v1 )`. An app's address keeps only the start of a long name (`appSlugFits`), so a + * name with fixed text keeps it whole and shortens the other parts, the first first, until its + * address holds the whole name. A name that already fits stays as it is. A name of only text it + * may shorten has nothing to tell apart, and only keeps to the length names may have. + */ +const appName = (naming: AppNaming, attempt: number) => { + const parts = attempt === 1 ? naming : [...naming, ` (v1 ${attempt})`]; + const tellsApart = parts.some((part) => typeof part === "string"); + const fits = (texts: readonly string[]) => { + const name = texts.join(""); + return name.length <= nameLength && (!tellsApart || appSlugFits(name)); + }; + let texts = parts.map((part) => (typeof part === "string" ? part : part.cut)); + for (const [index, part] of parts.entries()) { + if (fits(texts)) break; + if (typeof part === "string") continue; + texts = + shortenings(part.cut) + .map((cut) => texts.with(index, cut)) + .find(fits) ?? texts.with(index, "…"); + } + return texts.join(""); +}; + +/** + * The names the importer tries for an app, in order: its own, then retries numbered `(v1 N)`. Each + * has an address of its own, so a retry never tries an address already refused. + */ +export const appNames = (naming: AppNaming) => + Array.from({ length: nameAttempts }, (_, index) => appName(naming, index + 1)).filter( + (name, index, names) => names.findIndex((other) => appSlug(other) === appSlug(name)) === index, + ); + +/** An integration's one app's name: its label. */ +export const integrationNaming = (integration: IntegrationRow): AppNaming => [ + { cut: integrationLabel(integration) }, +]; + +/** An artifact's app's name: its title. */ +export const artifactNaming = (row: Pick): AppNaming => [ + { cut: row.title.trim() === "" ? "Artifact" : row.title }, +]; + /** * A connection's own app's name (decision 33): its integration's, then the connection's. A * workspace connection that shares its name with a member's says so, as its account does. App * names are unique in an organization, and members often gave their connections the same name, so - * each later connection of a name is numbered, in the order v1 made them. + * each later connection of a name is numbered, in the order v1 made them. When the name is too + * long for its address to hold, the integration's label is shortened first, then the connection's + * name; the marker and number stay whole (`appName`). */ -export const connectionAppName = ( +export const connectionAppNaming = ( snapshot: Pick, integration: IntegrationRow, connection: ConnectionRow, -) => { - const own = (row: ConnectionRow) => { - const name = row.name.trim() === "" ? "connection" : row.name.trim().slice(0, 56); - return accountLabel(snapshot, row) === row.name ? name : `${name}, workspace`; - }; +): AppNaming => { + const nameOf = (row: ConnectionRow) => + row.name.trim() === "" ? "connection" : row.name.trim().slice(0, 56); + const own = (row: ConnectionRow) => + accountLabel(snapshot, row) === row.name ? nameOf(row) : `${nameOf(row)}, workspace`; const base = own(connection); const index = snapshot.connections .filter((row) => row.integration === connection.integration && own(row) === base) @@ -463,10 +538,22 @@ export const connectionAppName = ( : 0, ) .findIndex((row) => row.row_id === connection.row_id); - const label = index > 0 ? `${base} ${index + 1}` : base; - return `${integrationLabel(integration).slice(0, 117 - label.length)} (${label})`; + const marker = base === nameOf(connection) ? "" : ", workspace"; + return [ + { cut: integrationLabel(integration) }, + " (", + { cut: nameOf(connection) }, + `${marker}${index > 0 ? ` ${index + 1}` : ""})`, + ]; }; +/** A connection's own app's name, as its first deploy tries it (`connectionAppNaming`). */ +export const connectionAppName = ( + snapshot: Pick, + integration: IntegrationRow, + connection: ConnectionRow, +) => appName(connectionAppNaming(snapshot, integration, connection), 1); + /** An item context narrowed to one connection's own app (decision 33). */ const connectionContext = ( context: C, @@ -594,10 +681,11 @@ export const toolActions = ( ) => { const actions = new Map(); const toolkits = decodedToolkits(snapshot); + const resolve = policyResolver(snapshot.policies, snapshot.multiMember); for (const tool of tools) { const id = `${integration.slug}.${tool.owner}.${tool.connection}.${tool.name}`; const fallback = unruled(integration, tool); - const own = resolvePolicy(id, snapshot.policies, snapshot.multiMember) ?? fallback; + const own = resolve(id) ?? fallback; // The least the app may do for each toolkit that grants the tool: ask when the toolkit asks, // keep the tool's default when the toolkit left it to the default. const action = toolkits.reduce((current, toolkit) => { @@ -1312,12 +1400,14 @@ export const generate = ( } }).pipe(Effect.catchTag("TemplateError", (error) => fail(error.reason))); -/** Deploy under the integration's name, then a numbered name when the organization has it. */ -const deployNamed = (context: ItemContext, name: string, files: SourceFiles) => +/** + * Deploy under the app's name, then a numbered one when another app in the organization has its + * name or address (`appNames`). + */ +const deployNamed = (context: ItemContext, naming: AppNaming, files: SourceFiles) => Effect.gen(function* () { const owner = organizationOwner(context.target.id); - for (let attempt = 1; attempt <= 5; attempt++) { - const candidate = attempt === 1 ? name : `${name.slice(0, 110)} (v1 ${attempt})`; + for (const candidate of appNames(naming)) { const scoped = yield* scopeGeneratedPackage(files, context.target.slug, candidate).pipe( Effect.catch(() => fail("The app package could not be named.")), ); @@ -1332,7 +1422,7 @@ const deployNamed = (context: ItemContext, name: string, files: SourceFiles) => ); if (Option.isSome(deployed)) return deployed.value; } - return yield* fail("Every name for this app is taken in the organization."); + return yield* fail(namesTaken); }); /** @@ -1374,7 +1464,7 @@ export const importIntegration = ( const owner = organizationOwner(context.target.id); const app = existing === undefined - ? yield* deployNamed(context, integrationLabel(integration), generated.files) + ? yield* deployNamed(context, integrationNaming(integration), generated.files) : (yield* context.executor.apps.deploy({ owner, app: existing, @@ -1430,10 +1520,11 @@ export const importConnectionApp = ( yield* redundantFixed(own, integration), ); const generated = yield* generate(own, integration, plan.value, planned); + const naming = connectionAppNaming(context.snapshot, integration, connection); const name = connectionAppName(context.snapshot, integration, connection); const app = existing === undefined - ? yield* deployNamed(context, name, generated.files) + ? yield* deployNamed(context, naming, generated.files) : (yield* context.executor.apps.deploy({ owner: organizationOwner(context.target.id), app: existing, @@ -1444,7 +1535,8 @@ export const importConnectionApp = ( return { result: imported("app", app.id, generated.note), app: app.id, - name, + // The name it has: a retry's when an earlier app took the first (`appNames`). + name: app.name, traced: generated.traced, stale: generated.stale, dropped: generated.dropped, @@ -2281,6 +2373,100 @@ const sameTarget = (a: RunTarget, b: RunTarget) => const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`; +/** `read` once per key; a failed read is not kept, so a later call reads again. */ +const readOnce = (key: (row: R) => string, read: (row: R) => Effect.Effect) => { + const known = new Map(); + return (row: R): Effect.Effect => { + const found = known.get(key(row)); + return found !== undefined + ? Effect.succeed(found.value) + : read(row).pipe(Effect.tap((value) => Effect.sync(() => known.set(key(row), { value })))); + }; +}; + +/** An integration a toolkit reaches, as its app was imported, with every member's tools. */ +interface ReachedIntegration { + readonly imported: ImportedIntegration; + readonly all: readonly ToolRow[]; + readonly aliases: Readonly>; + /** + * The names the app a tool runs in leaves out: the integration's one app's, or, for an app per + * connection, the tool's connection's own app's. None for a tool without its connection there. + */ + readonly omitted: (connection: ConnectionRow | undefined) => ReadonlySet | undefined; +} + +/** + * What every member's copy of a step's toolkits is planned from, read and decided once for the + * step: each integration's imported app, tools and the names its app leaves out, each + * connection's own app and what it runs as, and what each toolkit does with each tool. None of it + * depends on the member, and a toolkit's copies write none of what `lookup` reads, so every copy + * reads the same. + */ +export const toolkitPlanning = ( + context: { readonly snapshot: V1Snapshot; readonly source: Pick }, + lookup: ImportedLookup, +) => { + const { snapshot, source } = context; + // The first of v1's connections at each address, as a tool names its connection. + const addresses = new Map(); + const address = (integration: string, owner: string, subject: string, name: string) => + JSON.stringify([integration, owner, subject, name]); + for (const connection of snapshot.connections) { + const key = address( + connection.integration, + connection.owner, + connection.subject, + connection.name, + ); + if (!addresses.has(key)) addresses.set(key, connection); + } + const integration = readOnce( + (row: IntegrationRow) => row.row_id, + (row) => + Effect.gen(function* () { + const imported = yield* lookup.app(row); + if (imported === undefined) return undefined; + // Approvals are the app's, for every member, so they read every member's tools. + const all = yield* source.tools(snapshot.tenant, snapshot.subjects, row.slug); + const aliases = row.plugin_id === "mcp" ? mcpNaming(all).aliases : {}; + const omittedOf = (tools: readonly ToolRow[]) => + new Set(approvalRules(toolActions(snapshot, row, tools, aliases)).omitted); + const integrationOmitted = imported.kind === "one" ? omittedOf(all) : undefined; + // A connection's own app leaves out what v1's rules blocked for it alone. + const ownOmitted = new Map>(); + const omitted = (connection: ConnectionRow | undefined) => { + if (integrationOmitted !== undefined || connection === undefined) + return integrationOmitted; + const own = ownOmitted.get(connection.row_id) ?? omittedOf(listedAs(connection, all)); + ownOmitted.set(connection.row_id, own); + return own; + }; + return { imported, all, aliases, omitted } satisfies ReachedIntegration; + }), + ); + // What each toolkit does with each tool, which is the same for every member's copy. + const actions = new Map>>(); + return { + snapshot, + integration, + action: (toolkit: V1Toolkit, toolId: string) => { + const known = actions.get(toolkit) ?? new Map>(); + actions.set(toolkit, known); + if (known.has(toolId)) return known.get(toolId); + const action = toolkitAction(toolkit, toolId); + known.set(toolId, action); + return action; + }, + /** The connection v1 listed a tool of the integration through. */ + connectionOf: (row: Pick, tool: ToolRow) => + addresses.get(address(row.slug, tool.owner, tool.subject, tool.connection)), + connectionApp: readOnce((row: ConnectionRow) => row.row_id, lookup.connectionApp), + connection: readOnce((row: ConnectionRow) => row.row_id, lookup.connection), + }; +}; +export type ToolkitPlanning = ReturnType; + /** * A toolkit's scoped MCP connection: for each imported app whose tools it granted, the tools v1 * served through it (`selected`, by the app's names) and what the connections it granted run as: @@ -2292,14 +2478,9 @@ const plural = (count: number, one: string, many: string) => `${count} ${count = * Each member gets their own copy (decision 11), which reaches org connections and, for the * member's own toolkit, their own connections; never another member's. */ -export const toolkitPolicy = ( - context: { readonly snapshot: V1Snapshot; readonly source: Pick }, - lookup: ImportedLookup, - toolkit: V1Toolkit, - member: string, -) => +export const toolkitPolicy = (planning: ToolkitPlanning, toolkit: V1Toolkit, member: string) => Effect.gen(function* () { - const { snapshot, source } = context; + const { snapshot } = planning; const apps: ConnectionApp[] = []; const notes: string[] = []; const missingApps = new Set(); @@ -2310,19 +2491,13 @@ export const toolkitPolicy = ( const reached = toolkitConnections(snapshot, toolkit).filter(visible); for (const integration of snapshot.integrations) { if (!reached.some((connection) => connection.integration === integration.slug)) continue; - const imported = yield* lookup.app(integration); + const found = yield* planning.integration(integration); const label = integrationLabel(integration); - if (imported === undefined) { + if (found === undefined) { missingApps.add(label); continue; } - // Approvals are the app's, for every member, so they read every member's tools. - const all = yield* source.tools(snapshot.tenant, snapshot.subjects, integration.slug); - const aliases = integration.plugin_id === "mcp" ? mcpNaming(all).aliases : {}; - const omittedOf = (tools: readonly ToolRow[]) => - new Set(approvalRules(toolActions(snapshot, integration, tools, aliases)).omitted); - const integrationOmitted = imported.kind === "one" ? omittedOf(all) : undefined; - const ownOmitted = new Map>(); + const { imported, all, aliases } = found; const tools = all.filter(visible); // The tools granted through each app, the connections granted, and which tool through which. const byApp = new Map< @@ -2330,7 +2505,7 @@ export const toolkitPolicy = ( { names: Set; granted: Map; pairs: Set } >(); for (const tool of tools) { - const action = toolkitAction( + const action = planning.action( toolkit, `${integration.slug}.${tool.owner}.${tool.connection}.${tool.name}`, ); @@ -2338,26 +2513,14 @@ export const toolkitPolicy = ( const name = Object.hasOwn(aliases, tool.name) ? (aliases[tool.name] ?? tool.name) : tool.name; - const connection = snapshot.connections.find( - (candidate) => - candidate.integration === integration.slug && - candidate.owner === tool.owner && - candidate.subject === tool.subject && - candidate.name === tool.connection, - ); - // A connection's own app leaves out what v1's rules blocked for it alone. - let omitted: ReadonlySet | undefined = integrationOmitted; - if (omitted === undefined && connection !== undefined) { - omitted = ownOmitted.get(connection.row_id) ?? omittedOf(listedAs(connection, all)); - ownOmitted.set(connection.row_id, omitted); - } - if (omitted?.has(name) === true) { + const connection = planning.connectionOf(integration, tool); + if (found.omitted(connection)?.has(name) === true) { blockedElsewhere.add(`${label} ${name}`); continue; } if (connection === undefined) continue; const appId = - imported.kind === "one" ? imported.id : yield* lookup.connectionApp(connection); + imported.kind === "one" ? imported.id : yield* planning.connectionApp(connection); if (appId === undefined) { missingApps.add(connectionAppName(snapshot, integration, connection)); continue; @@ -2375,7 +2538,7 @@ export const toolkitPolicy = ( for (const [appId, { names, granted, pairs }] of byApp) { const targets: RunTarget[] = []; for (const connection of granted.values()) { - const target = (yield* lookup.connection(connection))?.target; + const target = (yield* planning.connection(connection))?.target; if (target === undefined) missingConnections.add(`${label} ${connection.name}`); else if (!targets.some((other) => sameTarget(other, target))) targets.push(target); } @@ -2410,14 +2573,30 @@ export const toolkitPolicy = ( }); /** - * The connection's id: stable for the toolkit, the member's copy and the organization, so a retry - * updates it. + * The connection's id: stable for the toolkit, the member's copy, the organization and the + * generation, so a retry updates it. */ -export const toolkitConnectionId = (organization: string, toolkit: V1Toolkit, member: string) => - toolkitCopyConnectionId(organization, toolkit.row.row_id, member).pipe( +export const toolkitConnectionId = ( + organization: string, + toolkit: V1Toolkit, + member: string, + generation: number, +) => + toolkitCopyConnectionId(organization, toolkit.row.row_id, member, generation).pipe( Effect.map((id) => ConnectionId.make(id)), ); +/** + * What the import map holds for one member's copy of a toolkit: the connection an earlier run saved + * it as, a row for an attempt that saved none, or no row at all. Only a rollback deletes map rows, + * so a connection that already holds an unmapped copy's id was made by an import that was rolled + * back. + */ +export type ToolkitCopyRecord = + | { readonly kind: "saved"; readonly connection: string } + | { readonly kind: "unsaved" } + | { readonly kind: "unmapped" }; + /** The members who get a copy of a toolkit: everyone for an org toolkit, else its owner. */ export const toolkitMembers = (toolkit: V1Toolkit, members: readonly string[]) => toolkit.row.owner === "org" @@ -2429,39 +2608,70 @@ export const toolkitCopyKey = (toolkit: Pick, member: string) toolkitCopyId(toolkit.row.row_id, member); /** - * Create or update a member's copy of the toolkit: a scoped MCP connection they own. `copy` is the - * connection the import map already records for it, which for a copy the opt-in import made is not - * the id an import gives a copy now. + * Create or update a member's copy of the toolkit: a scoped MCP connection they own. A copy the + * import map records keeps its connection, which for a copy the opt-in import made is not the id an + * import gives a copy now. Any other copy takes generation 0's id. When another connection holds + * an unmapped copy's id, one a rolled-back import made, the copy takes the next generation's: that + * connection stays revoked under its id, so its grants and tokens never come back. An owner may + * make a connection again with a revoked connection's id, which would revive this one, so the + * import does not, on the first attempt or a failed retry: it leaves the revoked copy and uses + * the next generation. Otherwise a taken id means its users removed the copy. `planning` is the + * step's, shared by every copy it saves. */ export const importToolkit = ( context: ItemContext, + planning: ToolkitPlanning, toolkit: V1Toolkit, member: string, - copy?: string, + record: ToolkitCopyRecord, ) => Effect.gen(function* () { const user = context.users.get(member); if (user === undefined) return yield* fail("Its member is not imported."); - const { policy, notes } = yield* toolkitPolicy( - context, - mapLookup(context.store, context.snapshot), - toolkit, - member, - ); + const { policy, notes } = yield* toolkitPolicy(planning, toolkit, member); const owner = { userId: user, resource: context.target.id }; - const id = - copy === undefined - ? yield* toolkitConnectionId(context.target.id, toolkit, member) - : ConnectionId.make(copy); const name = (toolkit.data.name.trim() === "" ? toolkit.data.slug : toolkit.data.name).slice( 0, 80, ); - const existing = yield* context.connections.list(owner); - const saved = existing.some((connection) => connection.id === id) - ? yield* context.connections.update(owner, { id, name, policy }) - : yield* context.connections.create(owner, { id, name, policy }); - return imported("mcp_connection", saved.id, notes.length === 0 ? undefined : notes.join(" ")); + const note = notes.length === 0 ? undefined : notes.join(" "); + const live = yield* context.connections.list(owner); + const save = (id: ConnectionId) => + Effect.gen(function* () { + return live.some((connection) => connection.id === id) + ? yield* context.connections.update(owner, { id, name, policy }) + : yield* context.connections.create(owner, { id, name, policy }); + }); + // A revoked connection is absent from `live`, and creating with its id would revive it. An + // unmapped copy leaves that connection revoked and takes another generation. + const RevokedHolder = Schema.Array(Schema.Struct({ revoked: Schema.Boolean })); + const heldByRevokedCopy = (id: ConnectionId) => + context.sql` + select revoked from "mcpConnection" + where id = ${id} and "userId" = ${owner.userId} and resource = ${owner.resource} + `.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(RevokedHolder)), + Effect.map((rows) => rows.some((row) => row.revoked)), + Effect.mapError(() => new V1ImportUnavailable()), + ); + if (record.kind === "saved") { + const saved = yield* save(ConnectionId.make(record.connection)); + return imported("mcp_connection", saved.id, note); + } + // Each rollback leaves at most one generation of a copy behind, so the walk ends at the first + // generation after them. A retry finds the copy it saved there live and updates it. + for (let generation = 0; ; generation++) { + const id = yield* toolkitConnectionId(context.target.id, toolkit, member, generation); + // Saved copies returned above. A failed retry is `unsaved` and must not revive either. + if (yield* heldByRevokedCopy(id)) continue; + const saved = yield* save(id).pipe( + Effect.map(Option.some), + Effect.catchTag("ConnectionIdTaken", (taken) => + record.kind === "unmapped" ? Effect.succeedNone : Effect.fail(taken), + ), + ); + if (Option.isSome(saved)) return imported("mcp_connection", saved.value.id, note); + } }).pipe( Effect.catchTags({ ConnectionIdTaken: () => fail("Its MCP connection was removed in v2."), @@ -2701,9 +2911,7 @@ const saveArtifact = ( }).pipe(Effect.catchTag("AppNotFound", () => Effect.succeed(Option.none()))); if (Option.isSome(updated)) return { ...imported("undeployed_app", updated.value, note) }; } - for (let attempt = 1; attempt <= 5; attempt++) { - const candidate = - attempt === 1 ? title.slice(0, 120) : `${title.slice(0, 110)} (v1 ${attempt})`; + for (const candidate of appNames(artifactNaming(row))) { const scoped = yield* scopeGeneratedPackage(files, context.target.slug, candidate).pipe( Effect.catch(() => fail("The app package could not be named.")), ); @@ -2718,5 +2926,5 @@ const saveArtifact = ( ); if (Option.isSome(created)) return imported("undeployed_app", created.value, note); } - return yield* fail("Every name for this app is taken in the organization."); + return yield* fail(namesTaken); }); diff --git a/apps/hosted/cloud/src/implementation/v1-import/members.ts b/apps/hosted/cloud/src/implementation/v1-import/members.ts index b5b490fa23..11a289a2eb 100644 --- a/apps/hosted/cloud/src/implementation/v1-import/members.ts +++ b/apps/hosted/cloud/src/implementation/v1-import/members.ts @@ -90,7 +90,10 @@ export const planMembers = ( /** v1's billing for an organization, from the plans Autumn bills it on. */ export type V1Billing = | { readonly kind: "free" } - /** Enterprise orgs move by hand, after their v2 SSO is set up (decision 15). */ + /** + * Enterprise orgs move by hand (decision 15): only when the operator lists one to copy into an + * existing v2 organization, carrying nothing over (decision 34). + */ | { readonly kind: "enterprise"; readonly plans: readonly string[] } /** * v1's grandfathered Hobby and Professional plans, which no Stripe subscription bills. The diff --git a/apps/hosted/cloud/src/implementation/v1-import/plan.ts b/apps/hosted/cloud/src/implementation/v1-import/plan.ts index 6e28f24a2d..935e23f5b3 100644 --- a/apps/hosted/cloud/src/implementation/v1-import/plan.ts +++ b/apps/hosted/cloud/src/implementation/v1-import/plan.ts @@ -12,7 +12,7 @@ import { credentialLike, decodeIntegration, decodeItemIds, - decodeToolkit, + decodeToolkits, firstPartyClientPrefix, placementVariables, tokenEndpointAuthMethod, @@ -509,10 +509,7 @@ export const connectionPlan = ( /** The workspace's toolkits that decode, with their rules. */ export const decodedToolkits = (snapshot: Pick) => - snapshot.toolkits.flatMap((row) => { - const toolkit = decodeToolkit(row, snapshot.toolkitRules); - return toolkit === undefined ? [] : [toolkit]; - }); + decodeToolkits(snapshot.toolkits, snapshot.toolkitRules); /** The note on a toolkit whose access patterns reach none of the workspace's connections. */ export const grantsNoConnection = "It grants no connection in this workspace."; diff --git a/apps/hosted/cloud/src/implementation/v1-import/run.ts b/apps/hosted/cloud/src/implementation/v1-import/run.ts index 0ed595ebcc..5315ea94e0 100644 --- a/apps/hosted/cloud/src/implementation/v1-import/run.ts +++ b/apps/hosted/cloud/src/implementation/v1-import/run.ts @@ -18,7 +18,14 @@ import { type McpConnectionStore, type OrganizationId, } from "@executor-js/hosted-server"; -import { AccountId, AppId, OAuthGrantImport, OwnerId, type Executor } from "@executor-js/sdk/core"; +import { + AccountId, + AppId, + appSlug, + OAuthGrantImport, + OwnerId, + type Executor, +} from "@executor-js/sdk/core"; import { Clock, Context, @@ -70,11 +77,14 @@ import { } from "./identity.ts"; import { ItemFailed, + appNames, artifactMapping, + artifactNaming, checkAccount, checkFixedAccount, checkIntegration, connectionAppName, + connectionAppNaming, connectionAppOf, connectionPlans, connectionTarget, @@ -85,8 +95,10 @@ import { importFixedAccount, importIntegration, importToolkit, + integrationNaming, linkConnectionAccount, mapLookup, + namesTaken, needsNoAccount, readSecretCompared, refreshCopiedGrant, @@ -94,14 +106,17 @@ import { toolkitCopyKey, toolkitMembers, updateCopiedAccount, + toolkitPlanning, toolkitPolicy, type AppAccountRequirements, + type AppNaming, type AppScope, type DroppedFixed, type ImportedLookup, type ItemContext, type RuleCounts, type StaleTool, + type ToolkitCopyRecord, } from "./items.ts"; import { memberProfile, @@ -578,9 +593,9 @@ const assessCarryOver = ( * (`intoOwner`). `nothing_to_copy` and `leave_v1_data` move a free workspace of one active member * with no pending invitation, which WorkOS lists as a pending membership; `nothing_to_copy` also * needs it to have nothing to copy, no item of any kind the import moves. `copy_into_empty` takes - * any workspace an import takes, paid or shared, into an organization with nothing set up - * (`setUpIn`), or the one an earlier run recorded. An import reads it under the freeze, as the rest - * of the assessment. + * any workspace an import takes, paid, Enterprise or shared, into an organization with nothing set + * up (`setUpIn`), or the one an earlier run recorded. An import reads it under the freeze, as the + * rest of the assessment. */ const intoPlan = ( sql: SqlClient.SqlClient, @@ -636,9 +651,11 @@ const intoPlan = ( /** * Read the organization's members, plan and rows, and decide whether it moves now. Enterprise - * organizations move by hand (decision 15). A paid organization moves with its v1 subscription - * (decision 5) when it can carry over (`assessCarryOver`); an import holds it back otherwise. One - * on a grandfathered Hobby or Professional plan moves to v2's Free plan, as a free one does. + * organizations move by hand (decision 15): only when the operator lists one to copy into an + * existing v2 organization, with nothing carried over (decision 34). A paid organization moves with + * its v1 subscription (decision 5) when it can carry over (`assessCarryOver`); an import holds it + * back otherwise. One on a grandfathered Hobby or Professional plan moves to v2's Free plan, as a + * free one does. */ const assess = (params: V1ImportParams) => Effect.gen(function* () { @@ -752,7 +769,8 @@ const assess = (params: V1ImportParams) => changed: copy.slug !== snapshot.organization.slug, }; // A copy into an existing organization carries billing over as into a copy: its own customer - // must bill nothing. + // must bill nothing. Only a paid organization carries over; an Enterprise one carries nothing, + // so its copy never holds, renames or deletes a v2 customer (decision 34). const carry = billing.kind === "paid" ? yield* assessCarryOver( @@ -761,10 +779,13 @@ const assess = (params: V1ImportParams) => copy?.id ?? (into?.kind === "into" ? (into.target.id as OrganizationId) : undefined), ) : null; + // Moving an Enterprise organization by hand means the operator lists it to copy into an + // existing v2 organization (decision 34); otherwise it is held. + const listedCopy = into?.kind === "into" && listed?.mode === "copy_into_empty"; const held = into?.kind === "refused" ? `It does not move into the listed v2 organization: ${into.refused}` - : billing.kind === "enterprise" + : billing.kind === "enterprise" && !listedCopy ? "Enterprise organizations move by hand, after their v2 SSO is set up." : carry !== null && carry.problem !== null && params.mode === "import" ? `Its v1 subscription cannot carry over: ${carry.problem}` @@ -864,6 +885,11 @@ interface Outcome { * (decision 33): each connection's app. */ readonly apps?: readonly ConnectionAppOutcome[] | undefined; + /** + * The name of the app a dry run plans for an integration or artifact, for its check that each + * app gets one (`namedApps`). Absent from steps that ran before it was kept. + */ + readonly naming?: AppNaming | undefined; } /** One connection's own app (decision 33): what it did or would do, and its rules' counts. */ @@ -880,6 +906,8 @@ interface ConnectionAppOutcome { readonly rules?: RuleCounts | undefined; /** As an integration's `accounts`: what a dry run's build of this app takes. */ readonly accounts?: AppAccountRequirements | undefined; + /** As an integration's `naming`: the name a dry run plans for this app. */ + readonly naming?: AppNaming | undefined; } /** What the user should know about an integration that moves as an app per connection. */ @@ -1194,6 +1222,7 @@ const dryIntegration = (assessment: Assessment, organization: string, id: string staleTools: checked.stale, droppedCredentials: checked.dropped, accounts: checked.accounts, + naming: integrationNaming(row), }; // As an import's `withFixedAccount`: a refused fixed account fails the integration, whose // app its connections still get. @@ -1241,6 +1270,7 @@ const dryConnectionApps = (checking: Parameters[0], row label: connection.name, owner: connection.owner, name: connectionAppName(checking.snapshot, row, connection), + naming: connectionAppNaming(checking.snapshot, row, connection), }; const failed = ( error: unknown, @@ -1268,6 +1298,42 @@ const dryConnectionApps = (checking: Parameters[0], row }, ); +/** + * The dry run's check that each app it plans gets a name. App names and addresses are unique in + * an organization, so it names the apps as an import would, in the order the import makes them: + * each takes the first of its names (`appNames`) whose address no earlier app took, and one left + * with none fails, as its import would (`namesTaken`). A connection's own app is planned under the + * name it takes. It does not read the target organization's apps: a copy goes only into an + * organization with none of its own (decision 32). + */ +const namedApps = (outcomes: readonly Outcome[]): readonly Outcome[] => { + const taken = new Set(); + /** The name an app takes, or undefined when every one is taken. */ + const take = (naming: AppNaming) => { + const name = appNames(naming).find((candidate) => !taken.has(appSlug(candidate))); + if (name !== undefined) taken.add(appSlug(name)); + return name; + }; + return outcomes.map((outcome): Outcome => { + if (outcome.apps !== undefined) { + const apps = outcome.apps.map((app): ConnectionAppOutcome => { + if (app.status !== "planned" || app.naming === undefined) return app; + const name = take(app.naming); + return name === undefined + ? { ...app, status: "failed", reason: namesTaken } + : { ...app, name }; + }); + return apps.some((app, index) => app.status !== outcome.apps?.[index]?.status) + ? { ...outcome, status: "failed", reason: splitFailure(apps), apps } + : { ...outcome, apps }; + } + if (outcome.status !== "planned" || outcome.naming === undefined) return outcome; + return take(outcome.naming) === undefined + ? { ...outcome, status: "failed", reason: namesTaken } + : outcome; + }); +}; + /** The integrations a dry run plans as an app per connection, by row id. */ const splitIntegrations = (outcomes: readonly Outcome[]): ReadonlySet => new Set( @@ -1443,7 +1509,11 @@ const dryToolkits = (assessment: Assessment, organization: string, split: Readon organization, allSnapshotTables, ); - const lookup = plannedLookup(snapshot, firstParty, split); + // Every copy of every toolkit is planned from the same apps, tools and accounts. + const planning = toolkitPlanning( + { snapshot, source }, + plannedLookup(snapshot, firstParty, split), + ); // Only the toolkits the assessment plans, as an import moves: none when v1's data stays in v1. const planned = new Set(assessment.items.toolkits); const rows = snapshot.toolkits.filter((row) => planned.has(row.row_id)); @@ -1461,9 +1531,7 @@ const dryToolkits = (assessment: Assessment, organization: string, split: Readon } satisfies Outcome; const members = toolkitMembers(toolkit, snapshot.subjects); // Plan each member's copy as an import would. - yield* Effect.forEach(members, (member) => - toolkitPolicy({ snapshot, source }, lookup, toolkit, member), - ); + yield* Effect.forEach(members, (member) => toolkitPolicy(planning, toolkit, member)); return { kind: "toolkit", id: row.row_id, @@ -1496,7 +1564,11 @@ const dryArtifacts = (assessment: Assessment, organization: string, split: Reado "clients", ]); const lookup = plannedLookup(snapshot, firstParty, split); - const rows = yield* artifactRows(source, snapshot, assessment.items.artifacts); + const ids = assessment.items.artifacts; + // In the order an import saves them, so the dry run names them as it would (`namedApps`). + const rows = (yield* artifactRows(source, snapshot, ids)).toSorted( + (a, b) => ids.indexOf(a.row_id) - ids.indexOf(b.row_id), + ); return yield* Effect.forEach(rows, (row) => artifactMapping(snapshot, lookup, row).pipe( Effect.map((mapping): Outcome => ({ @@ -1506,6 +1578,7 @@ const dryArtifacts = (assessment: Assessment, organization: string, split: Reado context: null, status: "planned", needsRepair: true, + naming: artifactNaming(row), unmappedRoles: mapping.roles.flatMap((role) => role.problem === null ? [] : [{ role: role.role, problem: role.problem }], ), @@ -2104,6 +2177,8 @@ const importToolkitItem = (context: ItemContext, id: string) => } const base = { kind: "toolkit", id, label: toolkit.data.name, context: null } as const; const members = toolkitMembers(toolkit, snapshot.subjects); + // Every member's copy follows the same imported apps and accounts. + const planning = toolkitPlanning(context, mapLookup(context.store, snapshot)); const copies = yield* Effect.forEach(members, (member) => { const key = toolkitCopyKey(toolkit, member); return Effect.gen(function* () { @@ -2137,9 +2212,15 @@ const importToolkitItem = (context: ItemContext, id: string) => note: keptInV2, }); // Otherwise its connection follows the apps and accounts imported since; saving it again - // is safe. - const copy = existing?.status === "imported" ? existing.v2_id : null; - return yield* importToolkit(context, toolkit, member, copy ?? undefined).pipe( + // is safe. A failed attempt keeps the connection it already saved, so the retry updates + // that copy instead of starting again at generation 0. + const record: ToolkitCopyRecord = + existing === undefined + ? { kind: "unmapped" } + : existing.v2_id !== null + ? { kind: "saved", connection: existing.v2_id } + : { kind: "unsaved" }; + return yield* importToolkit(context, planning, toolkit, member, record).pipe( Effect.tap(recorded(context, "toolkit", key, hash)), Effect.map((result) => outcomeOf(base, result)), Effect.catch(failedItem(context, "toolkit", key, hash, base)), @@ -2176,8 +2257,10 @@ const importArtifactItem = (context: ItemContext, id: string) => // Its source and the mapping written into it. const hash = yield* sourceHash({ artifact: row, mapping }); const existing = yield* store.find("artifact", id); + // A failed attempt keeps the app it already created, so the retry updates that app. + const keptId = existing !== undefined && existing.v2_id !== null ? existing.v2_id : undefined; const since = - existing?.status === "imported" && existing.v2_id !== null + existing?.status === "imported" && keptId !== undefined ? yield* store.sinceImported("artifact", id) : "kept"; // An app its users removed or deployed again in v2 since stays as they left it. @@ -2198,10 +2281,7 @@ const importArtifactItem = (context: ItemContext, id: string) => ? {} : { note: existing.reason }), }); - const app = - existing?.status === "imported" && existing.v2_id !== null - ? AppId.make(existing.v2_id) - : undefined; + const app = keptId === undefined ? undefined : AppId.make(keptId); return yield* importArtifact(context, row, mapping, app).pipe( Effect.tap(recorded(context, "artifact", id, hash)), Effect.map((result) => ({ @@ -2352,6 +2432,7 @@ const verify = ( */ const blocking = (report: Omit, assessment: Assessment) => report.integrationFailures.length > 0 || + report.artifacts.some((artifact) => artifact.error !== undefined) || report.connectionFailures.some((failure) => failure.reconnect !== true) || report.firstPartyMismatches.length > 0 || assessment.emailMatches.some((match) => match.match === "conflict"); @@ -2577,7 +2658,14 @@ const reportOf = ( : { read: false }, artifacts: outcomes.flatMap((outcome) => outcome.kind === "artifact" - ? [{ id: outcome.id, title: outcome.label, unmappedRoles: outcome.unmappedRoles ?? [] }] + ? [ + { + id: outcome.id, + title: outcome.label, + unmappedRoles: outcome.unmappedRoles ?? [], + ...(outcome.status === "failed" ? { error: outcome.reason ?? "" } : {}), + }, + ] : [], ), splitIntegrations: outcomes.flatMap((outcome) => @@ -3036,7 +3124,7 @@ export const runV1Import = ( ), )), ); - return yield* finish(assessment, outcomes, undefined, undefined, undefined); + return yield* finish(assessment, namedApps(outcomes), undefined, undefined, undefined); } const written = yield* step( diff --git a/apps/hosted/cloud/src/implementation/v1-import/store.ts b/apps/hosted/cloud/src/implementation/v1-import/store.ts index ace63d2430..86c3362f96 100644 --- a/apps/hosted/cloud/src/implementation/v1-import/store.ts +++ b/apps/hosted/cloud/src/implementation/v1-import/store.ts @@ -37,14 +37,23 @@ export type MapKind = export const toolkitCopyId = (toolkitRow: string, member: string) => `${toolkitRow}:${member}`; /** - * The id the import gives a member's copy of a toolkit in a v2 organization, which v1 computes too - * when it forwards the toolkit's URL. A copy the opt-in import made before copies were per member + * The id the import gives a member's copy of a toolkit in a v2 organization. Generation 0 is the + * id v1 computes too when it forwards the toolkit's URL. A rollback leaves the copies it revoked + * under their ids, which are never used again, so an import after it gives such a copy the next + * generation's id (`importToolkit`). A copy the opt-in import made before copies were per member * keeps the id it got then (`adoptToolkitCopy`). */ -export const toolkitCopyConnectionId = (organization: string, toolkitRow: string, member: string) => - sourceHash([organization, toolkitRow, member]).pipe( - Effect.map((hash) => `v1-${hash.slice(0, 40)}`), - ); +export const toolkitCopyConnectionId = ( + organization: string, + toolkitRow: string, + member: string, + generation: number, +) => + sourceHash( + generation === 0 + ? [organization, toolkitRow, member] + : [organization, toolkitRow, member, generation], + ).pipe(Effect.map((hash) => `v1-${hash.slice(0, 40)}`)); export const MapRow = Schema.Struct({ v1_kind: Schema.String, @@ -104,7 +113,11 @@ export const makeImportStore = (sql: SqlClient.SqlClient) => { Effect.map((found) => found[0]), unavailable, ); - /** Record an attempt. A v1 item keeps one row; a later attempt replaces its result. */ + /** + * Record an attempt. A v1 item keeps one row; a later attempt replaces its result. A failure + * keeps the v2 id an earlier attempt saved, so a retry updates that resource instead of + * starting again. + */ const record = (input: { readonly organization: string; readonly kind: MapKind; @@ -121,8 +134,12 @@ export const makeImportStore = (sql: SqlClient.SqlClient) => { values (${input.organization}, ${input.kind}, ${input.id}, ${imported?.v2Kind ?? null}, ${imported?.v2Id ?? null}, ${input.result.status}, ${reason}, ${input.hash}, now()) on conflict (v1_kind, v1_id) do update set - v2_kind = excluded.v2_kind, v2_id = excluded.v2_id, status = excluded.status, - reason = excluded.reason, source_hash = excluded.source_hash, updated_at = now() + v2_kind = case when excluded.status = 'failed' + then coalesce(excluded.v2_kind, private_v1_import_map.v2_kind) else excluded.v2_kind end, + v2_id = case when excluded.status = 'failed' + then coalesce(excluded.v2_id, private_v1_import_map.v2_id) else excluded.v2_id end, + status = excluded.status, reason = excluded.reason, + source_hash = excluded.source_hash, updated_at = now() where private_v1_import_map.v1_org_id = excluded.v1_org_id `.pipe(Effect.asVoid, unavailable); }; diff --git a/apps/hosted/cloud/src/implementation/v1-mcp-forward.ts b/apps/hosted/cloud/src/implementation/v1-mcp-forward.ts index 5fd7a8d710..8b004fe72c 100644 --- a/apps/hosted/cloud/src/implementation/v1-mcp-forward.ts +++ b/apps/hosted/cloud/src/implementation/v1-mcp-forward.ts @@ -268,7 +268,8 @@ export const v1ForwardAccess = ( ) return yield* new McpForbidden({ reason: "organization_mismatch" }); // A toolkit runs under the caller's copy, the scoped connection its import made for them. v1 - // names it by the id the import gives a copy; one the opt-in import made keeps its earlier id. + // names it by the id the import gives a copy first, generation 0's; one the opt-in import made + // keeps its earlier id, and one made again after a rollback has a later generation's. const copy = row.toolkitConnection; if (claims.toolkit !== undefined) { if (copy === null) return yield* forbidden; @@ -276,6 +277,7 @@ export const v1ForwardAccess = ( row.organization, claims.toolkit, claims.account, + 0, ); if (claims.connection !== copy && claims.connection !== derived) return yield* forbidden; } diff --git a/apps/hosted/cloud/web/package.json b/apps/hosted/cloud/web/package.json index fdd31ae3b8..a17ce25d49 100644 --- a/apps/hosted/cloud/web/package.json +++ b/apps/hosted/cloud/web/package.json @@ -16,8 +16,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@better-auth/passkey": "1.7.5", - "@better-auth/sso": "1.7.5", + "@better-auth/passkey": "1.7.16", + "@better-auth/sso": "1.7.16", "@effect/atom-react": "4.0.1", "@executor-js/dashboard-start": "workspace:*", "@executor-js/devtools": "workspace:*", @@ -30,7 +30,7 @@ "@sentry/react": "10.75.0", "@tanstack/react-router": "1.170.41", "@tanstack/react-start": "1.168.60", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "posthog-js": "1.434.7", "react": "19.3.0", diff --git a/apps/hosted/self-host/package.json b/apps/hosted/self-host/package.json index b72aa6371c..33be1a76ef 100644 --- a/apps/hosted/self-host/package.json +++ b/apps/hosted/self-host/package.json @@ -9,7 +9,7 @@ "package:runtime": "bun scripts/package-runtime.ts" }, "dependencies": { - "@better-auth/core": "1.7.5", + "@better-auth/core": "1.7.16", "@effect/platform-browser": "4.0.1", "@effect/platform-bun": "4.0.1", "@effect/platform-node": "4.0.1", @@ -28,7 +28,7 @@ "@executor-js/telemetry": "workspace:*", "@executor-js/utils": "workspace:*", "apps": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "fumadb-effect": "workspace:*", "kysely": "0.29.6", diff --git a/apps/hosted/server/package.json b/apps/hosted/server/package.json index 4a21fe593f..39fbcce590 100644 --- a/apps/hosted/server/package.json +++ b/apps/hosted/server/package.json @@ -30,9 +30,9 @@ "./publishers": "./src/implementation/publishers.ts" }, "dependencies": { - "@better-auth/api-key": "1.7.5", - "@better-auth/core": "1.7.5", - "@better-auth/oauth-provider": "1.7.5", + "@better-auth/api-key": "1.7.16", + "@better-auth/core": "1.7.16", + "@better-auth/oauth-provider": "1.7.16", "@executor-js/app-management": "workspace:*", "@executor-js/app-registry": "workspace:*", "@executor-js/app-templates": "workspace:*", @@ -45,7 +45,7 @@ "@executor-js/telemetry": "workspace:*", "@executor-js/utils": "workspace:*", "apps": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1" }, "devDependencies": {} diff --git a/apps/hosted/server/src/implementation/bearer-access.ts b/apps/hosted/server/src/implementation/bearer-access.ts index 992248ec6c..f654d93dc0 100644 --- a/apps/hosted/server/src/implementation/bearer-access.ts +++ b/apps/hosted/server/src/implementation/bearer-access.ts @@ -9,7 +9,7 @@ * needs in one statement and applies the same checks to the result. * * The checks mirror `@better-auth/oauth-provider`'s opaque access token validation and - * `@better-auth/api-key`'s verification (both 1.7.5, as configured in `auth.ts` and + * `@better-auth/api-key`'s verification (both 1.7.16, as configured in `auth.ts` and * `api-keys.ts`), followed by the grant rules in `@executor-js/mcp-auth`. Expiry is decided * with the application clock after the statement returns, as Better Auth decides it, never * with the database clock. diff --git a/apps/hosted/testing/accounts.ts b/apps/hosted/testing/accounts.ts index c0fb721b28..585989493b 100644 --- a/apps/hosted/testing/accounts.ts +++ b/apps/hosted/testing/accounts.ts @@ -57,7 +57,7 @@ export const testAccountAuth = (settings: { organization({ disableOrganizationDeletion: true }), { ...helpers, - // Better Auth 1.7.5 emits options: undefined; omit it for exact optional property types. + // Better Auth 1.7.16 emits options: undefined; omit it for exact optional property types. init(ctx: Parameters[0]) { const { options, ...result } = helpers.init(ctx); return { ...result, ...(options === undefined ? {} : { options }) }; diff --git a/apps/hosted/testing/package.json b/apps/hosted/testing/package.json index 26381e7007..e82a684989 100644 --- a/apps/hosted/testing/package.json +++ b/apps/hosted/testing/package.json @@ -13,7 +13,7 @@ "@executor-js/devtools": "workspace:*", "@executor-js/hosted-server": "workspace:*", "@executor-js/telemetry": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "pg": "8.16.3", "samlify": "2.13.1" diff --git a/apps/hosted/web/package.json b/apps/hosted/web/package.json index bc6906896d..68ab68145a 100644 --- a/apps/hosted/web/package.json +++ b/apps/hosted/web/package.json @@ -27,7 +27,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@better-auth/oauth-provider": "1.7.5", + "@better-auth/oauth-provider": "1.7.16", "@effect/atom-react": "4.0.1", "@executor-js/catalog": "workspace:*", "@executor-js/dashboard-start": "workspace:*", @@ -44,7 +44,7 @@ "@tanstack/react-form": "1.33.5", "@tanstack/react-router": "1.170.41", "@tanstack/react-start": "1.168.60", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "react": "19.3.0", "react-dom": "19.3.0" diff --git a/apps/local/server/package.json b/apps/local/server/package.json index 6ef293a5a4..91659c981e 100644 --- a/apps/local/server/package.json +++ b/apps/local/server/package.json @@ -35,7 +35,7 @@ "@executor-js/utils": "workspace:*", "@napi-rs/keyring": "2.1.0", "apps": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "fumadb-effect": "workspace:*", "proper-lockfile": "4.1.2" diff --git a/bun.lock b/bun.lock index 7b14621998..e8823e1265 100644 --- a/bun.lock +++ b/bun.lock @@ -40,7 +40,7 @@ }, "apps/cli": { "name": "executor", - "version": "2.0.0-beta.34", + "version": "2.0.0-beta.35", }, "apps/docs": { "name": "@executor-js/docs", @@ -60,9 +60,9 @@ "name": "@executor-js/hosted-cloud", "dependencies": { "@alchemy.run/better-auth": "2.0.0-beta.80", - "@better-auth/core": "1.7.5", - "@better-auth/passkey": "1.7.5", - "@better-auth/sso": "1.7.5", + "@better-auth/core": "1.7.16", + "@better-auth/passkey": "1.7.16", + "@better-auth/sso": "1.7.16", "@cloudflare/worker-bundler": "0.2.4", "@distilled.cloud/cloudflare": "1.0.0-rc.13", "@distilled.cloud/posthog": "1.0.0-rc.13", @@ -89,7 +89,7 @@ "alchemy": "2.0.0-beta.80", "apps": "workspace:*", "autumn-js": "1.2.28", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "fumadb-effect": "workspace:*", "js-yaml": "5.4.3", @@ -113,8 +113,8 @@ "apps/hosted/cloud/web": { "name": "@executor-js/hosted-cloud-web", "dependencies": { - "@better-auth/passkey": "1.7.5", - "@better-auth/sso": "1.7.5", + "@better-auth/passkey": "1.7.16", + "@better-auth/sso": "1.7.16", "@effect/atom-react": "4.0.1", "@executor-js/dashboard-start": "workspace:*", "@executor-js/devtools": "workspace:*", @@ -127,7 +127,7 @@ "@sentry/react": "10.75.0", "@tanstack/react-router": "1.170.41", "@tanstack/react-start": "1.168.60", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "posthog-js": "1.434.7", "react": "19.3.0", @@ -144,7 +144,7 @@ "apps/hosted/self-host": { "name": "@executor-js/hosted-self-host", "dependencies": { - "@better-auth/core": "1.7.5", + "@better-auth/core": "1.7.16", "@effect/platform-browser": "4.0.1", "@effect/platform-bun": "4.0.1", "@effect/platform-node": "4.0.1", @@ -163,7 +163,7 @@ "@executor-js/telemetry": "workspace:*", "@executor-js/utils": "workspace:*", "apps": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "fumadb-effect": "workspace:*", "kysely": "0.29.6", @@ -205,9 +205,9 @@ "apps/hosted/server": { "name": "@executor-js/hosted-server", "dependencies": { - "@better-auth/api-key": "1.7.5", - "@better-auth/core": "1.7.5", - "@better-auth/oauth-provider": "1.7.5", + "@better-auth/api-key": "1.7.16", + "@better-auth/core": "1.7.16", + "@better-auth/oauth-provider": "1.7.16", "@executor-js/app-management": "workspace:*", "@executor-js/app-registry": "workspace:*", "@executor-js/app-templates": "workspace:*", @@ -220,7 +220,7 @@ "@executor-js/telemetry": "workspace:*", "@executor-js/utils": "workspace:*", "apps": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", }, }, @@ -234,7 +234,7 @@ "@executor-js/devtools": "workspace:*", "@executor-js/hosted-server": "workspace:*", "@executor-js/telemetry": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "pg": "8.16.3", "samlify": "2.13.1", @@ -246,7 +246,7 @@ "apps/hosted/web": { "name": "@executor-js/hosted-web", "dependencies": { - "@better-auth/oauth-provider": "1.7.5", + "@better-auth/oauth-provider": "1.7.16", "@effect/atom-react": "4.0.1", "@executor-js/catalog": "workspace:*", "@executor-js/dashboard-start": "workspace:*", @@ -263,7 +263,7 @@ "@tanstack/react-form": "1.33.5", "@tanstack/react-router": "1.170.41", "@tanstack/react-start": "1.168.60", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "react": "19.3.0", "react-dom": "19.3.0", @@ -320,7 +320,7 @@ "@executor-js/utils": "workspace:*", "@napi-rs/keyring": "2.1.0", "apps": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "fumadb-effect": "workspace:*", "proper-lockfile": "4.1.2", @@ -488,7 +488,7 @@ }, "packages/apps": { "name": "apps", - "version": "0.0.1-beta.119.shuv.1", + "version": "0.0.1-beta.120.shuv.1", "dependencies": { "@cfworker/json-schema": "4.1.1", "@csstools/css-tokenizer": "4.0.2", @@ -570,7 +570,7 @@ "@effect/atom-react": "4.0.1", "@executor-js/ui": "workspace:*", "@executor-js/utils": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "radix-ui": "1.6.7", }, @@ -619,14 +619,14 @@ "packages/mcp-auth": { "name": "@executor-js/mcp-auth", "dependencies": { - "@better-auth/core": "1.7.5", - "@better-auth/oauth-provider": "1.7.5", + "@better-auth/core": "1.7.16", + "@better-auth/oauth-provider": "1.7.16", "@effect/sql-pglite": "4.0.1", "@executor-js/authorization": "workspace:*", "@executor-js/mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/utils": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "kysely": "0.29.6", }, @@ -845,29 +845,28 @@ "workerd", ], "patchedDependencies": { + "tree-sitter@0.21.1": "patches/tree-sitter@0.21.1.patch", "@effect/sql-pglite@4.0.1": "patches/@effect%2Fsql-pglite@4.0.1.patch", - "@better-auth/api-key@1.7.5": "patches/@better-auth%2Fapi-key@1.7.5.patch", + "better-auth@1.7.16": "patches/better-auth@1.7.16.patch", "alchemy@2.0.0-beta.80": "patches/alchemy@2.0.0-beta.80.patch", + "@better-auth/passkey@1.7.16": "patches/@better-auth%2Fpasskey@1.7.16.patch", "@cfworker/json-schema@4.1.1": "patches/@cfworker%2Fjson-schema@4.1.1.patch", - "pg-cloudflare@1.4.0": "patches/pg-cloudflare@1.4.0.patch", + "@better-auth/api-key@1.7.16": "patches/@better-auth%2Fapi-key@1.7.16.patch", "tailwindcss-iso@1.0.7": "patches/tailwindcss-iso@1.0.7.patch", - "@better-auth/passkey@1.7.5": "patches/@better-auth%2Fpasskey@1.7.5.patch", + "pg-cloudflare@1.4.0": "patches/pg-cloudflare@1.4.0.patch", "@swagger-api/apidom-ns-openapi-3-1@1.11.6": "patches/@swagger-api%2Fapidom-ns-openapi-3-1@1.11.6.patch", "@electric-sql/pglite@0.5.8": "patches/@electric-sql%2Fpglite@0.5.8.patch", - "better-auth@1.7.5": "patches/better-auth@1.7.5.patch", - "@better-auth/core@1.7.5": "patches/@better-auth%2Fcore@1.7.5.patch", "tree-sitter@0.22.4": "patches/tree-sitter@0.22.4.patch", "@cloudflare/worker-bundler@0.2.4": "patches/@cloudflare%2Fworker-bundler@0.2.4.patch", "@effect/sql-pg@4.0.1": "patches/@effect%2Fsql-pg@4.0.1.patch", - "@better-auth/oauth-provider@1.7.5": "patches/@better-auth%2Foauth-provider@1.7.5.patch", "@effect/platform-node-shared@4.0.1": "patches/@effect%2Fplatform-node-shared@4.0.1.patch", "effect@4.0.1": "patches/effect@4.0.1.patch", + "@better-auth/sso@1.7.16": "patches/@better-auth%2Fsso@1.7.16.patch", "@distilled.cloud/cloudflare@1.0.0-rc.13": "patches/@distilled.cloud%2Fcloudflare@1.0.0-rc.13.patch", - "@better-auth/sso@1.7.5": "patches/@better-auth%2Fsso@1.7.5.patch", "@alchemy.run/cloudflare-runtime@2.0.0-beta.80": "patches/@alchemy.run%2Fcloudflare-runtime@2.0.0-beta.80.patch", "miniflare@4.20260730.0": "patches/miniflare@4.20260730.0.patch", + "@better-auth/oauth-provider@1.7.16": "patches/@better-auth%2Foauth-provider@1.7.16.patch", "@opencode-ai/codemode@0.0.0-dev-19272": "patches/@opencode-ai%2Fcodemode@0.0.0-dev-19272.patch", - "tree-sitter@0.21.1": "patches/tree-sitter@0.21.1.patch", }, "overrides": { "@effect/platform-browser": "4.0.1", @@ -897,6 +896,8 @@ "@astrojs/astro2tsx": ["@astrojs/astro2tsx@0.1.0", "", { "dependencies": { "@emnapi/core": "1.11.3", "@napi-rs/wasm-runtime": "1.2.4" } }, "sha512-tgprkax8mcF+BiHJQBdoQ+REqtvvnLlYzV0yCeLMdcv3pJIutdlinWNHUzN1jMOT5VRlYTtMmDdaoPrtd3cBAQ=="], + "@astrojs/check": ["@astrojs/check@0.9.10", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], + "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.4.1", "", { "optionalDependencies": { "@astrojs/compiler-binding-android-arm64": "0.4.1", "@astrojs/compiler-binding-darwin-arm64": "0.4.1", "@astrojs/compiler-binding-darwin-x64": "0.4.1", "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.1", "@astrojs/compiler-binding-linux-arm64-musl": "0.4.1", "@astrojs/compiler-binding-linux-x64-gnu": "0.4.1", "@astrojs/compiler-binding-linux-x64-musl": "0.4.1", "@astrojs/compiler-binding-wasm32-wasi": "0.4.1", "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.1", "@astrojs/compiler-binding-win32-x64-msvc": "0.4.1" } }, "sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA=="], "@astrojs/compiler-binding-android-arm64": ["@astrojs/compiler-binding-android-arm64@0.4.1", "", { "os": "android", "cpu": "arm64" }, "sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog=="], @@ -1025,31 +1026,31 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@better-auth/api-key": ["@better-auth/api-key@1.7.5", "", { "dependencies": { "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "better-auth": "^1.7.5", "better-call": "1.4.0" } }, "sha512-wrRacs0Gx2MyoOITjjrEBCR5dTxLVhCqkXhRNbeZXveVMBOXtCBPnruB0z+sccNAcynz0496IXVJq/AVexgN0g=="], + "@better-auth/api-key": ["@better-auth/api-key@1.7.16", "", { "dependencies": { "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "better-auth": "^1.7.16", "better-call": "1.4.5" } }, "sha512-IXDVe1RPUQ65iERFEL7VxjCfC2aoaBjGHvyytMLinX3MJJ4GIWqEYog9fiQhYQclz+Gloh0UJ7J3p1/C7P9qQg=="], - "@better-auth/core": ["@better-auth/core@1.7.5", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-kVlSu4H8OKQfjg4b/Zj5MOaospt83N0JbX38wsDzE58Yw95jzovFkU3pxzB1eUFYc4mkuhUMZD8iT1gpUjNMcQ=="], + "@better-auth/core": ["@better-auth/core@1.7.16", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.5", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-dwVAtEAEpjPsCV/TqOKRHreWyuiqvja+TnTTAuVbi3P0afM0+b1+ga+RTXNjVE86Q4HRwRg45Ek3txFrY8/1DA=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-9SM7v1735SoaedRDcDbHc5ULgXEd2vUlEJkvRHpMF2Q9qf59TRh1b5A9hryyecyi56bm/0CNUDU3nY0uVWj5/Q=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.16", "", { "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-8cgtcWkCmJal+ecrl681Y+SKvMDy5Vg2ydCOPE7X8Mcx1Ud7Mqve3pASG0I2JWMJtrge1m8YbpNmCxhVGh+gpQ=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-1wE5gvnjW+c1i4GrLtL9HLn3s0Xrq4YneCDan1NO1dpz0mEguLFNlzfnUGykTFrDwvFh2X5rkIbA8ALCj6WzXQ=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.16", "", { "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-MkLZwHxhYbVr1QGGDJDZXMT1g54Q5yucDMdLGaz3Y5P8ERRfaqVEe+3MCVCb8XxJ7Rh+VNJKUix4VOBq7vXimg=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2" } }, "sha512-YDmnfR9zOXbn5SNYYwfHBPuc19hg1d1C1vUXb+Hm8Q91pTsstFNX5OTlZbo7f28q06FpogAEfGGCN/2QV39cig=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.16", "", { "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0" } }, "sha512-b0uUE3mhQJDu2QqLDN3XVHhXUhpr6zK+UhjL7IdNteTJJaAMhCcXfpLc7+kpOcNVEEBdRiFXBwsEtxRjcB3rLQ=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-Yq0LfF0VlA9Kfjcjp/v43MSsChv7vKd1ct0Mt15px4zlqaCPIGfPbjw9YNM6jTo0fGpqLR0n15PllSWmLLRp9g=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.16", "", { "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-uZG9DhCHv7QcF8HtEIgYEL7Lawu0/1Pcp6ABe4ImYQdLV3HNRaAuQU/PxVxFQFAMSpnCVi4Rwg8+Qk8qVn2YMg=="], - "@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.7.5", "", { "dependencies": { "jose": "^6.2.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "better-auth": "^1.7.5", "better-call": "1.4.0" } }, "sha512-hVHb1TfydO8hWJXeZaGeJ+1506Fn+8hsffes+TFwi6/gl/ui+GqJVliPhHhuaOqpC1gN8ZFiLr0u8bh0aSnTLg=="], + "@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.7.16", "", { "dependencies": { "jose": "^6.2.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "better-auth": "^1.7.16", "better-call": "1.4.5" } }, "sha512-EyuCAgGJ976JjWDs9JJhna5H1uO7LbVs8lgfXADGXIffNJOpNR2vg8jl7J3FiKEZMhcs2Pg8uc9lkLz+mrF94w=="], - "@better-auth/passkey": ["@better-auth/passkey@1.7.5", "", { "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.1", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "better-auth": "^1.7.5", "better-call": "1.4.0", "nanostores": "^1.0.1" } }, "sha512-7bV1d1+zLGKO3ugH4bxQKG9s2cLuMsDnpxVhQ4QNnEWjSHKmWgpIwoUsFHk3MHz0ntE7au1QLk8HYkcenW5XyQ=="], + "@better-auth/passkey": ["@better-auth/passkey@1.7.16", "", { "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.2", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "better-auth": "^1.7.16", "better-call": "1.4.5", "nanostores": "^1.0.1" } }, "sha512-+AoRMdmA1ftBmN4SS9dFhyQ6B0+4zOd09gVRNXa+mVhO/+v9wcXMWTnfKn+36y98mwafLOLMClM3tKhPhkqqXQ=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-QfW6HS9vK0FMcbI/GsQLdplICxOz0EPzYWGONZT4ovL3cSItd4YH/09USbPPVl+VUQCdznAQIk+n+NKvHdmSag=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.16", "", { "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-rSVMa4sWxvEURqVR+o4SZvq6dosCvD58nLw2LOw7woz791/CKOMxhrqrjiHLLzx+OfzGO0Ak9x7lrFTUwYO/KQ=="], - "@better-auth/sso": ["@better-auth/sso@1.7.5", "", { "dependencies": { "@xmldom/xmldom": "^0.9.12", "fast-xml-parser": "^5.8.0", "jose": "^6.2.3", "samlify": "^2.13.1", "tldts": "^7.4.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "better-auth": "^1.7.5", "better-call": "1.4.0" } }, "sha512-sddwWyT4W/0mx/p7P1FmArXsFrKdoSo2BKJBkrc0gCm+HT4znYrwITWkRkwj26kx1Oz6nbNLvYOFc/6VoHcGUw=="], + "@better-auth/sso": ["@better-auth/sso@1.7.16", "", { "dependencies": { "@xmldom/xmldom": "^0.9.12", "fast-xml-parser": "^5.8.0", "jose": "^6.2.3", "samlify": "^2.13.1", "tldts": "^7.4.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "better-auth": "^1.7.16", "better-call": "1.4.5" } }, "sha512-PhSSeGudD3vLolBM+E/+L/YWil0JaMeS2zuiYkGJb1h3eUTDJuMsXNRWOy/2LL6FTkfAiC1iqUn/WlsgohPeGQ=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.7.5", "", { "peerDependencies": { "@better-auth/core": "^1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2" } }, "sha512-e/REPqMy9Em+gC6G0xWBikiMLRuy532Er7jqdoNkPBa65FbywgWcm1cZbgdW5CnHsrM3OLZsmKn14yeGoDISeg=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.7.16", "", { "peerDependencies": { "@better-auth/core": "^1.7.16", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3" } }, "sha512-mwQC0xB8KO636CjBxMZCNgglaIwh18DOemBJyNCy7DBOngkjPdNggPWYB8BpJ8wMqcn3SaKchdWZrNNqLRZV4Q=="], - "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + "@better-auth/utils": ["@better-auth/utils@0.5.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg=="], - "@better-fetch/fetch": ["@better-fetch/fetch@1.3.2", "", {}, "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow=="], + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.3", "", {}, "sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg=="], "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], @@ -1289,6 +1290,10 @@ "@electric-sql/pglite": ["@electric-sql/pglite@0.5.8", "", {}, "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q=="], + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], + "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -1821,8 +1826,6 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], @@ -3033,9 +3036,9 @@ "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "better-auth": ["better-auth@1.7.5", "", { "dependencies": { "@better-auth/core": "1.7.5", "@better-auth/drizzle-adapter": "1.7.5", "@better-auth/kysely-adapter": "1.7.5", "@better-auth/memory-adapter": "1.7.5", "@better-auth/mongo-adapter": "1.7.5", "@better-auth/prisma-adapter": "1.7.5", "@better-auth/telemetry": "1.7.5", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.2", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-aKE0Zt2EPTpFvmq4/oATNyG/mAfc6JUqWkW9pzGlrVnzUb0lso7GJ9BPxD6JPhLqYV1a9zOAb0uAz1Q5fm+eHA=="], + "better-auth": ["better-auth@1.7.16", "", { "dependencies": { "@better-auth/core": "1.7.16", "@better-auth/drizzle-adapter": "1.7.16", "@better-auth/kysely-adapter": "1.7.16", "@better-auth/memory-adapter": "1.7.16", "@better-auth/mongo-adapter": "1.7.16", "@better-auth/prisma-adapter": "1.7.16", "@better-auth/telemetry": "1.7.16", "@better-auth/utils": "0.5.1", "@better-fetch/fetch": "1.3.3", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.5", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0 || ^3.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-ur6xj8jeUC9lcH8ni9LrwB4FiFMPDTWFJmb89yZ2SHsFSUobYgGMFsMq6aOorlVtqRr9ckTemRunCR6vzsO65Q=="], - "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], + "better-call": ["better-call@1.4.5", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-GVOWfn3pjEfUXn6GrfGf2h7tPAqj1oMLFBWBEswJrSkDKcLk6TIcjV0zexibcZZ0c5N1mkLMsPFopXWzWj/gDQ=="], "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], @@ -5085,6 +5088,10 @@ "@astrojs/astro2tsx/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + "@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "@astrojs/check/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], + "@astrojs/internal-helpers/js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], "@astrojs/internal-helpers/shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], @@ -5217,10 +5224,6 @@ "@prisma/dev/@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], - "@prisma/dev/@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], - - "@prisma/dev/@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], - "@prisma/dev/@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], @@ -5401,9 +5404,9 @@ "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], - "better-call/rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], + "better-call/@better-fetch/fetch": ["@better-fetch/fetch@1.3.2", "", {}, "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow=="], - "blume/@astrojs/check": ["@astrojs/check@0.9.10", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], + "better-call/rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], "blume/es-module-lexer": ["es-module-lexer@3.0.2", "", {}, "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg=="], @@ -5413,8 +5416,6 @@ "blume/shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], - "blume/twoslash": ["twoslash@0.3.9", "", { "dependencies": { "@typescript/vfs": "^1.6.4", "twoslash-protocol": "0.3.9" }, "peerDependencies": { "typescript": "^5.5.0 || ^6.0.0" } }, "sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q=="], - "blume/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "blume/undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], @@ -5607,10 +5608,6 @@ "vite-plus/@oxlint/plugins": ["@oxlint/plugins@1.79.0", "", {}, "sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg=="], - "vite-plus/@vitest/browser": ["@vitest/browser@4.1.11", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.11" } }, "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w=="], - - "vite-plus/@vitest/browser-preview": ["@vitest/browser-preview@4.1.11", "", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.11" }, "peerDependencies": { "vitest": "4.1.11" } }, "sha512-iPKSE6Ibayey6HFgK1V1/aHgyhx7HSRk1YMi+lnBZGmlIiNV5Uc7xRkD9Su8RDylTxDECK23t7kTHdRKoqSYDQ=="], - "vite-plus/@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], "vite-plus/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], @@ -5647,6 +5644,10 @@ "@astrojs/astro2tsx/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + "@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "@astrojs/check/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "@astrojs/internal-helpers/shiki/@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], "@astrojs/internal-helpers/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], @@ -5815,10 +5816,6 @@ "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "blume/@astrojs/check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "blume/@astrojs/check/yargs": ["yargs@18.1.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg=="], - "blume/shiki/@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], "blume/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], @@ -5979,8 +5976,6 @@ "unzipper/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "vite-plus/@vitest/browser/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "vite-plus/@vitest/mocker/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], "vite-plus/vitest/tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -5991,6 +5986,12 @@ "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "@astrojs/check/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "@astrojs/check/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@astrojs/check/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "@astrojs/react/vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="], "@astrojs/react/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="], @@ -6129,10 +6130,6 @@ "astro/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="], - "blume/@astrojs/check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - - "blume/@astrojs/check/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -6181,6 +6178,12 @@ "vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="], + "@astrojs/check/yargs/cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "@astrojs/check/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@manypkg/find-root/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "@puppeteer/browsers/yargs/cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -6189,12 +6192,6 @@ "@puppeteer/browsers/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "blume/@astrojs/check/yargs/cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "blume/@astrojs/check/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "blume/@astrojs/check/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -6202,11 +6199,5 @@ "node-html-parser/css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "blume/@astrojs/check/yargs/cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "blume/@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - - "blume/@astrojs/check/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], } } diff --git a/e2e/check-boundary.ts b/e2e/check-boundary.ts index 3deb2e1180..aeca0c5bb1 100644 --- a/e2e/check-boundary.ts +++ b/e2e/check-boundary.ts @@ -91,6 +91,9 @@ const check = Effect.gen(function* () { if (label.startsWith(`viewer${path.sep}`) && specifier === "media-chrome/react") return; // The only database driver: runner-applied legacy rows for declared scenarios. if (label === legacyStorage && specifier === "@electric-sql/pglite") return; + // Encrypts a proxy package with better-auth 1.7.16, resolved from the Cloud package. + if (label === `support${path.sep}oauth-proxy-state.ts` && specifier === "node:module") + return; if ( specifier.startsWith(".") && path.resolve(path.dirname(file), specifier) === path.join(root, legacyStorage) && diff --git a/e2e/support/cloud-environment.ts b/e2e/support/cloud-environment.ts index 9a2405e621..d5f6ded25d 100644 --- a/e2e/support/cloud-environment.ts +++ b/e2e/support/cloud-environment.ts @@ -238,6 +238,14 @@ export const startCloudEnvironment = (input: { mode: 0o600, }); yield* Effect.addFinalizer(() => fs.remove(v1Handoff).pipe(Effect.orDie)); + // Scenarios encrypt OAuth proxy state with the same secret this Cloud's guard decrypts. + const oauthProxySecretFile = `${directory}/oauth-proxy.json`; + yield* fs.writeFileString( + oauthProxySecretFile, + JSON.stringify({ secret: Redacted.value(oauthProxy.secret) }), + { mode: 0o600 }, + ); + yield* Effect.addFinalizer(() => fs.remove(oauthProxySecretFile).pipe(Effect.orDie)); const env = { PATH: [path.join(cloud, "node_modules/.bin"), process.env.PATH ?? ""].join( process.platform === "win32" ? ";" : ":", diff --git a/e2e/support/oauth-proxy-state.ts b/e2e/support/oauth-proxy-state.ts new file mode 100644 index 0000000000..a8504487b8 --- /dev/null +++ b/e2e/support/oauth-proxy-state.ts @@ -0,0 +1,53 @@ +/** + * An OAuth proxy state package encrypted the way better-auth 1.7.16 encrypts one: + * HKDF purpose keys `oauth-proxy-state` and `oauth-proxy-package`, then `symmetricEncrypt`. + * Resolved from the Cloud package so the scenario uses that install's patched export. + */ +import { createRequire } from "node:module"; +import { Effect, Schema } from "effect"; + +const betterAuthCrypto = createRequire( + new URL("../../apps/hosted/cloud/package.json", import.meta.url), +)("better-auth/crypto") as { + readonly derivePurposeKey: (secret: string, purpose: string) => string; + readonly symmetricEncrypt: (input: { + readonly key: string; + readonly data: string; + }) => Promise; +}; + +export class OauthProxyStateFailed extends Schema.TaggedError()( + "OauthProxyStateFailed", + { message: Schema.String }, +) {} + +/** The `state` query production's callback receives for a proxied sign-in to `callbackURL`. */ +export const oauthProxyState = (secret: string, callbackURL: string) => + Effect.tryPromise({ + try: () => { + const stateKey = betterAuthCrypto.derivePurposeKey(secret, "oauth-proxy-state"); + const packageKey = betterAuthCrypto.derivePurposeKey(secret, "oauth-proxy-package"); + return betterAuthCrypto + .symmetricEncrypt({ + key: stateKey, + data: JSON.stringify({ + callbackURL, + errorURL: callbackURL, + newUserURL: callbackURL, + codeVerifier: "verifier", + oauthState: "a".repeat(32), + }), + }) + .then((stateCookie) => + betterAuthCrypto.symmetricEncrypt({ + key: packageKey, + data: JSON.stringify({ + state: "a".repeat(32), + stateCookie, + isOAuthProxy: true, + }), + }), + ); + }, + catch: () => new OauthProxyStateFailed({ message: "Could not encrypt an OAuth proxy state." }), + }); diff --git a/e2e/support/v1-import.ts b/e2e/support/v1-import.ts index a9bae61330..c3f67b300f 100644 --- a/e2e/support/v1-import.ts +++ b/e2e/support/v1-import.ts @@ -97,6 +97,7 @@ export const V1Report = Schema.Struct({ id: Schema.String, title: Schema.String, unmappedRoles: Schema.Array(Schema.Struct({ role: Schema.String, problem: Schema.String })), + error: Schema.optionalKey(Schema.String), }), ), flags: Schema.Struct({ diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index dd2b2039aa..d80888be5a 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -493,6 +493,16 @@ export const scenarios = plan({ local: na("Role hosts are Cloud's; local serves one origin."), }, }, + cloudOAuthProxyRefusal: { + file: "cloud-oauth-proxy.spec.ts", + title: + "Production's OAuth proxy refuses an untrusted redirect and a state it cannot decrypt, and still accepts a trusted redirect", + targets: { + cloud: managedCloud, + "self-host": na("The OAuth proxy and role hosts are Cloud's."), + local: na("The OAuth proxy and role hosts are Cloud's."), + }, + }, cloudOAuthProxyOwnSignIn: { file: "cloud-oauth-proxy.spec.ts", title: "Cloud's own social sign-ins on app. return through the edge without its OAuth proxy", @@ -3563,6 +3573,17 @@ export const scenarios = plan({ local: na("Local runs apps through the same workerd runner and outbound as self-host."), }, }, + credentialHostsUnderscore: { + fixtures: "actors", + file: "credential-hosts.spec.ts", + title: + "a provider may declare a host with an underscore, as Docker names containers, and its credentials reach it", + targets: { + "self-host": scheduled, + cloud: na("Uses a loopback service; Cloud app Workers reach only public addresses."), + local: na("Local runs apps through the same workerd runner and outbound as self-host."), + }, + }, credentialHostsRestart: { fixtures: "actors", file: "credential-hosts.spec.ts", @@ -9163,10 +9184,16 @@ export const scenarios = plan({ "Cloud v1 import repairs an MCP app moved before v1's approval stamps became rules: it plans without writing, deploys the earlier importer's app with the stamp as its own rule, gives an app an earlier import deployed the importer's source now, leaves an app whose users turned its approvals off or dropped them in v2 as they chose, keeps the code of an app its users changed beside its approvals and adds v1's approvals beneath it, finds nothing left on a second run, and runs the version a repair replaced again only while the app still runs the repair's", targets: { ...cloudOnboarding, cloud: managedCloud }, }, + v1ImportRevokedRetry: { + file: "cloud-v1-import-run.spec.ts", + title: + "Cloud v1 import leaves a rolled-back toolkit copy revoked when a failed retry runs again, and the next copy stays the one rollback can reach", + targets: { ...cloudOnboarding, cloud: managedCloud }, + }, v1ImportRollback: { file: "cloud-v1-import-run.spec.ts", title: - "Cloud v1 import marks a verified workspace moved in v1 and gives it back on rollback only once its v2 organization is deleted, clearing its import map but its users", + "Cloud v1 import marks a verified workspace moved in v1 and gives it back on rollback only once its v2 organization is deleted, clearing its import map but its users, and imports it again into a new organization where every member's toolkit copy is live and v1's toolkit URL forwards to it", targets: { ...cloudOnboarding, cloud: managedCloud }, }, v1ImportPaidBilling: { @@ -9224,6 +9251,12 @@ export const scenarios = plan({ "Cloud v1 import retries v1 plan reads that time out or fail and still reports the organization", targets: { ...cloudOnboarding, cloud: managedCloud }, }, + v1ImportLongNames: { + file: "cloud-v1-import-run.spec.ts", + title: + "Cloud v1 import gives every app an address of its own when its name is longer than an address holds: a long integration label's apps per connection, numbered for members' connections of one long name, shorten the label so each keeps its connection's name and number, artifacts of one long title are numbered by retries that each change the address, the dry run plans those names and reports the artifact left without one, and the import started again after it is renamed keeps every app the first run made", + targets: { ...cloudOnboarding, cloud: managedCloud }, + }, v1ImportSplitNames: { file: "cloud-v1-import-run.spec.ts", title: @@ -9236,6 +9269,12 @@ export const scenarios = plan({ "Cloud v1 import moves a shared workspace on the server and cuts it over once it verifies: the oldest admin owns it, members keep their roles without a welcome email, org connections become shared accounts linked for everyone and members' their own, a member's approve rule is dropped while their block applies to everyone, every member gets their own copy of an org toolkit, and a former member's rows stay behind", targets: { ...cloudOnboarding, cloud: managedCloud }, }, + v1ImportToolkitScale: { + file: "cloud-v1-import-run.spec.ts", + title: + "Cloud v1 import dry runs and moves a workspace of 20 members with 10 toolkits of over a thousand rules over 8 MCP servers of 200 tools, one of them an app per connection, planning and saving every member's copy of each toolkit with exactly the tools its rules and the workspace's allow", + targets: { ...cloudOnboarding, cloud: managedCloud }, + }, v1ImportSlugDisplacement: { file: "cloud-v1-import-run.spec.ts", title: @@ -9257,13 +9296,19 @@ export const scenarios = plan({ v1ImportIntoCopy: { file: "cloud-v1-import-run.spec.ts", title: - "Cloud v1 import copies a paid, shared v1 workspace into the empty v2 team its owner owns when the operator lists it: the team keeps its name and slug, gains the workspace's app, accounts and toolkit copies, its teammate joins, its subscription carries over to the team, a second copy into the team is refused as it is no longer empty, and after its billing goes back a rollback removes only what the import added", + "Cloud v1 import copies a paid, shared v1 workspace into the empty v2 team its owner owns when the operator lists it: the team keeps its name and slug, gains the workspace's app, accounts and toolkit copies, its teammate joins, its subscription carries over to the team, a second copy into the team is refused as it is no longer empty, after its billing goes back a rollback removes only what the import added, and importing it again copies it into the team once more, each member's toolkit copy under a new live connection v1's toolkit URL forwards to while the first import's copies stay revoked", + targets: { ...cloudOnboarding, cloud: managedCloud }, + }, + v1ImportIntoEnterprise: { + file: "cloud-v1-import-run.spec.ts", + title: + "Cloud v1 import copies an Enterprise v1 workspace into the empty v2 team on v2's Enterprise plan that its owner owns when the operator lists it to copy: the dry run plans it with no billing and still flags SSO, the import copies its items, its newcomer joins, a member already in the team keeps their membership and role, the team's and v1's billing stay as they were with no hold, a rollback needs no billing return and removes only what the import added, and an Enterprise workspace the operator did not list is still held back", targets: { ...cloudOnboarding, cloud: managedCloud }, }, v1ImportIntoRefused: { file: "cloud-v1-import-run.spec.ts", title: - "Cloud v1 import refuses to move a v1 workspace into a listed existing v2 organization when it has items to copy, a second active member, a pending invitation or a paid plan, also when it would leave its data behind, when its owner has no v2 account or does not own the organization, also to copy into it, when the organization does not exist, and when the listed mode is unknown: the dry run says why, and the import holds it back and gives it to v1 again without writing its import map or changing the organization", + "Cloud v1 import refuses to move a v1 workspace into a listed existing v2 organization when it has items to copy, a second active member, a pending invitation or a paid plan, Enterprise included, also when it would leave its data behind, when its owner has no v2 account or does not own the organization, also to copy into it, when the organization does not exist, and when the listed mode is unknown: the dry run says why, and the import holds it back and gives it to v1 again without writing its import map or changing the organization", targets: { ...cloudOnboarding, cloud: managedCloud }, }, v1ImportAllowed: { diff --git a/e2e/tests/app-package.spec.ts b/e2e/tests/app-package.spec.ts index b4f2ab14da..06dfc1122c 100644 --- a/e2e/tests/app-package.spec.ts +++ b/e2e/tests/app-package.spec.ts @@ -147,7 +147,7 @@ layer(HostedLive, { excludeTestServices: true })("Packaged apps", (it) => { expect(rejected.body).toMatchObject({ _tag: "VersionBuildFailed", reason: - "This app's apps framework uses host protocol 99. This host supports protocol 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13. Declare a supported apps version.", + "This app's apps framework uses host protocol 99. This host supports protocol 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14. Declare a supported apps version.", }); expect((yield* call("queries.version")).body).toEqual(Published); expect( diff --git a/e2e/tests/cloud-oauth-proxy.spec.ts b/e2e/tests/cloud-oauth-proxy.spec.ts index d1c7dbdab8..3da811dffd 100644 --- a/e2e/tests/cloud-oauth-proxy.spec.ts +++ b/e2e/tests/cloud-oauth-proxy.spec.ts @@ -15,7 +15,7 @@ * when it trusts that origin, and the stage completes the sign-in itself. */ import { expect, layer } from "@effect/vitest"; -import { Effect, Schema } from "effect"; +import { Effect, FileSystem, Schema } from "effect"; import { Actors } from "../support/actors.ts"; import { Api, body } from "../support/api.ts"; import { appsManifest } from "../support/apps-release.ts"; @@ -25,6 +25,7 @@ import { Resource } from "../support/contracts.ts"; import { oauthSetupIssuer } from "../support/oauth-setup-issuer.ts"; import { Onboarding } from "../support/onboarding.ts"; import { Target } from "../support/platform.ts"; +import { oauthProxyState } from "../support/oauth-proxy-state.ts"; import { rawRequest, roleHost, targetHosts } from "../support/role-hosts.ts"; import { scenarios } from "../test-plan.ts"; import { connectionsPath } from "../support/app-accounts.ts"; @@ -283,4 +284,49 @@ export default defineApp({user: { accounts:{service} }},async()=>({tools:router( }), ), ); + + it.effect(scenarios.cloudOAuthProxyRefusal.title, (context) => + withCase( + context, + Effect.gen(function* () { + const target = yield* Target, + fs = yield* FileSystem.FileSystem; + const { browser } = targetHosts(target); + const { secret } = yield* fs + .readFileString(`${target.directory}/oauth-proxy.json`) + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ secret: Schema.String })), + ), + ), + ); + // The edge only bounces this callback to the browser origin, where the guard runs. + const callback = `${browser}/api/auth/callback/google`; + const ask = (state: string) => rawRequest(`${callback}?state=${encodeURIComponent(state)}`); + + const untrusted = yield* ask(yield* oauthProxyState(secret, "https://evil.example/steal")); + expect(untrusted.status, untrusted.text).toBe(403); + expect(untrusted.location).toBeNull(); + expect(untrusted.text).toContain("not a trusted origin"); + + const trusted = yield* ask(yield* oauthProxyState(secret, `${browser}/done`)); + expect(trusted.status, trusted.text).not.toBe(403); + expect(trusted.text).not.toContain("not a trusted origin"); + expect(trusted.location ?? "").not.toContain("evil.example"); + + const malformed = yield* ask("not-a-sign-in-token"); + expect(malformed.status, malformed.text).toBe(400); + expect(malformed.text).toContain("cannot be read"); + const undecryptable = yield* ask("ab".repeat(80)); + expect(undecryptable.status, undecryptable.text).toBe(400); + expect(undecryptable.text).toContain("cannot be read"); + + // A normal sign-in token is not a proxy package. The guard must not refuse it. + const own = yield* ask("a".repeat(32)); + expect(own.text).not.toContain("cannot be read"); + expect(own.text).not.toContain("not a trusted origin"); + }), + ), + ); }); diff --git a/e2e/tests/cloud-v1-import-run.spec.ts b/e2e/tests/cloud-v1-import-run.spec.ts index 777a725dad..3ed6cc6a3f 100644 --- a/e2e/tests/cloud-v1-import-run.spec.ts +++ b/e2e/tests/cloud-v1-import-run.spec.ts @@ -38,6 +38,7 @@ import { V1McpForwardSecret, v1ForwardBearer, v1McpForwardFile, + v1ToolkitConnectionId, } from "../support/v1-mcp-forward.ts"; import { scenarios } from "../test-plan.ts"; @@ -190,11 +191,16 @@ const personalApproveDropped = * `mine` connection of two members, and a `gone` connection of someone who left. Rules: one member * approves `search` for themselves, another blocks `delete_item`, and the former member blocked * `search`. An org toolkit grants the `team` connection; one member's own toolkit grants theirs. + * `toolkits` gives the two toolkits' rows their ids, which a toolkit copy's id is made from. */ const seedShared = ( organization: string, slug: string, members: { readonly first: string; readonly second: string; readonly former: string }, + toolkits: { readonly team: string; readonly mine: string } = { + team: randomUUID(), + mine: randomUUID(), + }, ) => Effect.gen(function* () { const emulators = yield* Emulators; @@ -257,9 +263,11 @@ const seedShared = ( collection: string, key: string, data: object, + rowId: string = randomUUID(), ) => row( - "insert into plugin_storage (tenant, owner, subject, plugin_id, collection, key, data) values ($1, $2, $3, 'toolkits', $4, $5, $6::json)", + "insert into plugin_storage (row_id, tenant, owner, subject, plugin_id, collection, key, data) values ($1, $2, $3, $4, 'toolkits', $5, $6, $7::json)", + rowId, organization, owner, subject, @@ -291,18 +299,28 @@ const seedShared = ( policy(members.second, `${tracker}.*.*.search`, "approve"), policy(members.first, `${tracker}.*.*.delete_item`, "block"), policy(members.former, `${tracker}.*.*.search`, "block"), - storage("org", "", "toolkits", "tk_team", { id: "tk_team", name: "Team kit", slug: "team" }), + storage( + "org", + "", + "toolkits", + "tk_team", + { id: "tk_team", name: "Team kit", slug: "team" }, + toolkits.team, + ), storage("org", "", "toolkitConnections", "tc_team", { id: "tc_team", toolkitId: "tk_team", pattern: `${tracker}.org.team.*`, position: "a0", }), - storage("user", members.first, "toolkits", "tk_mine", { - id: "tk_mine", - name: "Mine", - slug: "mine", - }), + storage( + "user", + members.first, + "toolkits", + "tk_mine", + { id: "tk_mine", name: "Mine", slug: "mine" }, + toolkits.mine, + ), storage("user", members.first, "toolkitConnections", "tc_mine", { id: "tc_mine", toolkitId: "tk_mine", @@ -313,6 +331,259 @@ const seedShared = ( return { tracker }; }); +/** + * A shared workspace whose toolkits are as large as a busy organization's: 20 members, 8 MCP + * servers of 200 tools each, 344 workspace rules, and 10 toolkits (8 the organization's, 2 members' + * own) with 58 connection rules and 1,052 policies. Every server has the organization's `team` + * connection; two members also have their own `mine` on the first, whose `team` alone v1 blocks a + * tool for, so it becomes an app per connection. The workspace blocks one tool on each other + * server. Each toolkit's policies name a tool each, so the tools a copy grants are the server's, + * less the toolkit's blocks and the workspace's. + */ +const toolkitScale = (organization: string, members: readonly string[]) => { + const prefix = `scale_${randomUUID().slice(0, 6)}`; + const servers = Array.from({ length: 8 }, (_, index) => ({ + slug: `${prefix}_${index}`, + label: `Scale ${index}`, + })); + const tools = Array.from({ length: 200 }, (_, index) => `tool_${String(index).padStart(3, "0")}`); + const slugOf = (server: number) => servers[server]?.slug ?? ""; + const memberOf = (index: number) => members[index] ?? ""; + const connections = [ + ...servers.map((server) => ({ + row_id: `${server.slug}-team`, + owner: "org", + subject: "", + integration: server.slug, + name: "team", + })), + ...[0, 1].map((index) => ({ + row_id: `${slugOf(0)}-mine-${index}`, + owner: "user", + subject: memberOf(index), + integration: slugOf(0), + name: "mine", + })), + ]; + const workspaceRules = [ + { owner: "org", subject: "", pattern: `${slugOf(0)}.org.team.tool_000`, action: "block" }, + ...servers.slice(1).map((server) => ({ + owner: "org", + subject: "", + pattern: `${server.slug}.*.*.tool_001`, + action: "block", + })), + // The rest ask or allow, so they shape each app's approvals but leave every tool in. + ...Array.from({ length: 336 }, (_, index) => ({ + owner: index % 3 === 0 ? "user" : "org", + subject: index % 3 === 0 ? memberOf(index % members.length) : "", + pattern: `${slugOf(index % 8)}.*.*.${tools[2 + (index % (tools.length - 2))]}`, + action: index % 2 === 0 ? "approve" : "require_approval", + })), + ].map((rule, index) => ({ + ...rule, + id: `rule-${index}`, + position: `a${String(index).padStart(4, "0")}`, + })); + /** The organization's toolkits are 0 to 7; 8 is the second member's own, 9 the first's. */ + const owners = [...Array.from({ length: 8 }, () => ""), memberOf(1), memberOf(0)]; + const kits = owners.map((subject, kit) => { + // Each organization toolkit reaches the `team` connection of seven or six servers. + const access = + kit === 8 + ? [`${slugOf(0)}.user.mine.*`, `${slugOf(1)}.org.team.*`] + : kit === 9 + ? [`${slugOf(0)}.*`, `${slugOf(2)}.org.team.*`] + : servers + .filter((_, server) => server !== kit && (kit < 6 || server !== (kit + 1) % 8)) + .map((server) => `${server.slug}.org.team.*`); + const policies = Array.from({ length: kit === 0 ? 500 : kit === 9 ? 64 : 61 }, (_, index) => ({ + server: index % 8, + tool: tools[2 + ((Math.floor(index / 8) + 7 * kit) % (tools.length - 2))] ?? "", + action: index % 7 === 0 ? "block" : index % 7 === 1 ? "require_approval" : "approve", + })); + return { id: `kit-${kit}`, name: `Kit ${kit}`, subject, access, policies }; + }); + const storage = kits.flatMap((kit) => { + const owner = kit.subject === "" ? "org" : "user"; + const rule = (collection: string, key: string, data: object) => ({ + owner, + subject: kit.subject, + collection, + key, + data: { id: key, toolkitId: kit.id, ...data }, + }); + return [ + { + owner, + subject: kit.subject, + collection: "toolkits", + key: kit.id, + data: { id: kit.id, name: kit.name, slug: kit.id }, + }, + ...kit.access.map((pattern, index) => + rule("toolkitConnections", `${kit.id}-c${index}`, { + pattern, + position: `a${index}`, + }), + ), + ...kit.policies.map((policy, index) => + rule("toolkitPolicies", `${kit.id}-p${index}`, { + pattern: `${slugOf(policy.server)}.*.*.${policy.tool}`, + action: policy.action, + position: `a${String(index).padStart(4, "0")}`, + }), + ), + ]; + }); + const keyOf = (connection: (typeof connections)[number]) => + `connection:${connection.owner}:${connection.integration}:${connection.name}:token`; + const mcp = (server: number) => ({ + transport: "remote", + endpoint: `https://scale-${server}.example.com/mcp`, + remoteTransport: "auto", + authenticationTemplate: [ + { slug: "header", kind: "apikey", placements: [{ carrier: "header", name: "X-Key" }] }, + ], + }); + const seed = Effect.gen(function* () { + const emulators = yield* Emulators; + for (const connection of connections) + yield* emulators.v1VaultObject( + `${connection.owner === "org" ? vaultScope(organization) : vaultScope(organization, connection.subject)}/secrets/${encodeURIComponent(keyOf(connection))}`, + `synthetic-${connection.row_id}`, + ); + yield* writeRows("v1-database.json", [ + row( + "insert into organizations (id, name, slug) values ($1, $2, $3)", + organization, + "Synthetic toolkit scale workspace", + `v1-${randomUUID().slice(0, 8)}`, + ), + ...servers.map((server, index) => + row( + "insert into integration (tenant, slug, plugin_id, name, config) values ($1, $2, 'mcp', $3, $4::json)", + organization, + server.slug, + server.label, + JSON.stringify(mcp(index)), + ), + ), + row( + `insert into connection (tenant, row_id, owner, subject, integration, name, template, item_ids) + select $1, c->>'row_id', c->>'owner', c->>'subject', c->>'integration', c->>'name', + 'header', json_build_object('token', c->>'key') + from json_array_elements($2::json) c`, + organization, + JSON.stringify( + connections.map((connection) => ({ ...connection, key: keyOf(connection) })), + ), + ), + // Every connection lists all 200 of its server's tools. + row( + `insert into tool (tenant, owner, subject, integration, connection, plugin_id, name, annotations) + select $1, c->>'owner', c->>'subject', c->>'integration', c->>'name', 'mcp', t.name, + json_build_object('mcp', json_build_object('toolName', t.name)) + from json_array_elements($2::json) c, + json_array_elements_text($3::json) as t(name)`, + organization, + JSON.stringify(connections), + JSON.stringify(tools), + ), + row( + `insert into tool_policy (tenant, owner, subject, id, pattern, action, position) + select $1, r->>'owner', r->>'subject', r->>'id', r->>'pattern', r->>'action', r->>'position' + from json_array_elements($2::json) r`, + organization, + JSON.stringify(workspaceRules), + ), + row( + `insert into plugin_storage (tenant, owner, subject, plugin_id, collection, key, data) + select $1, r->>'owner', r->>'subject', 'toolkits', r->>'collection', r->>'key', r->'data' + from json_array_elements($2::json) r`, + organization, + JSON.stringify(storage), + ), + ]); + }); + /** + * What each toolkit's copy grants, from its rules: for each server it reaches, the tools of each + * app it runs in, less what the toolkit blocks there and what the app leaves out, run as the + * app for `team` and as the member's own account for `mine`. `apps` names each server's app and + * `accounts` each connection's account, by connection row id. + */ + const expectedPolicy = ( + kit: (typeof kits)[number], + apps: ReadonlyMap, + accounts: ReadonlyMap, + ) => { + const granted = kit.access.flatMap((pattern) => { + const server = servers.findIndex((candidate) => pattern.startsWith(`${candidate.slug}.`)); + const through = connections.filter( + (connection) => + connection.integration === slugOf(server) && + (pattern.endsWith(".org.team.*") + ? connection.owner === "org" + : pattern.endsWith(".user.mine.*") + ? connection.subject === kit.subject + : connection.owner === "org" || connection.subject === kit.subject), + ); + return through.map((connection) => ({ server, connection })); + }); + const blocked = (server: number) => + new Set( + kit.policies + .filter((policy) => policy.server === server && policy.action === "block") + .map((policy) => policy.tool), + ); + return granted + .map(({ server, connection }) => { + // The first server's `team` app alone leaves out `tool_000`; the others' apps `tool_001`. + const left = + server === 0 ? (connection.owner === "org" ? "tool_000" : undefined) : "tool_001"; + return { + app: apps.get(server === 0 ? connection.row_id : connection.integration) ?? "unknown app", + runsAs: + connection.owner === "org" + ? [{ kind: "app" }] + : [{ kind: "account", id: accounts.get(connection.row_id) ?? "unknown account" }], + tools: { + kind: "selected", + names: tools.filter((tool) => tool !== left && !blocked(server).has(tool)), + }, + }; + }) + .toSorted((a, b) => (a.app < b.app ? -1 : 1)); + }; + /** The tools each organization toolkit grants that the workspace blocks, as its note lists them. */ + const blockedNote = (kit: (typeof kits)[number]) => { + const left = kit.access + .map((pattern) => servers.findIndex((server) => pattern.startsWith(`${server.slug}.`))) + .map((server) => `Scale ${server} ${server === 0 ? "tool_000" : "tool_001"}`) + .toSorted(); + return `${left.length} tools v1 allowed only through this toolkit are blocked in the workspace, so they are left out: ${left.join(", ")}.`; + }; + return { seed, servers, connections, kits, expectedPolicy, blockedNote }; +}; + +const ToolkitCopy = Schema.Struct({ + name: Schema.String, + userId: Schema.String, + policy: Schema.fromJsonString( + Schema.Struct({ + apps: Schema.Array( + Schema.Struct({ + app: Schema.String, + runsAs: Schema.Array( + Schema.Struct({ kind: Schema.String, id: Schema.optional(Schema.String) }), + ), + tools: Schema.Struct({ kind: Schema.String, names: Schema.Array(Schema.String) }), + }), + ), + }), + ), +}); + const IdRow = Schema.Struct({ id: Schema.String }); /** A synthetic address no other scenario uses. */ @@ -386,7 +657,438 @@ const goneRule = (organization: string) => `gone_${randomUUID().slice(0, 6)}.*.*.search`, ); +/** A v1 member: their WorkOS user id and their email. */ +interface V1Member { + readonly user: string; + readonly email: string; +} + +const byJson = (a: unknown, b: unknown) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1); + +const Copy = Schema.Struct({ id: Schema.String, name: Schema.String, userId: Schema.String }); +/** An organization's live MCP connections: its members' toolkit copies. */ +const liveCopies = (organization: string) => + cloudRows( + Copy, + `select id, name, "userId" from "mcpConnection" where resource = $1 and not revoked`, + organization, + ); +/** Whether each of these MCP connections is revoked. */ +const connectionsRevoked = (ids: readonly string[]) => + cloudRows( + Schema.Struct({ id: Schema.String, revoked: Schema.Boolean }), + `select id, revoked from "mcpConnection" + where id in (select jsonb_array_elements_text($1::jsonb)) order by id`, + JSON.stringify(ids), + ); + +/** + * The status of the initialize request v1 forwards when `member` opens the v1 URL of `toolkit`, a + * toolkit row of `organization`, once it moved to `v2Organization`. v1 names the member's copy by + * the id it computes. + */ +const forwardedToolkit = (input: { + readonly organization: string; + readonly v2Organization: string; + readonly toolkit: string; + readonly member: string; +}) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem, + target = yield* Target, + http = yield* HttpClient.HttpClient; + const { secret } = yield* fs + .readFileString(`${target.directory}/${v1McpForwardFile}`) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(V1McpForwardSecret)))); + const connection = v1ToolkitConnectionId(input.v2Organization, input.toolkit, input.member); + const bearer = v1ForwardBearer(secret, { + account: input.member, + organization: input.organization, + exp: Math.floor(Date.now() / 1000) + 300, + toolkit: input.toolkit, + connection, + }); + const request = HttpClientRequest.post( + `${target.metadata.origin}/mcp?connection=${connection}`, + ).pipe( + HttpClientRequest.setHeaders({ + accept: "application/json, text/event-stream", + authorization: `Bearer ${bearer}`, + }), + ); + const response = yield* http.execute( + yield* HttpClientRequest.bodyJson(request, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "forwarded-toolkit", version: "1" }, + }, + }), + ); + yield* response.text; + return response.status; + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)); + +/** + * Import a rolled-back workspace of the shared seed (`seedShared`) again and check its toolkit + * copies. Its own import instance has completed, so starting it again runs nothing; an operator + * restarts it. A twin workspace with the same members and toolkit rows stands in for that restart: + * a copy's id comes from the v2 organization, the toolkit's row and the member, so the twin's + * copies take the ids of the copies the first import made (`earlier`). It moves into `into` as a + * copy, or into a new organization. It cuts over, its owner and teammate each have a live copy of + * the org toolkit and the owner one of their own toolkit, none under an id of `earlier`, the import + * map names each, and v1's URL of the org toolkit forwards each member to their copy. The twin and + * its report. + */ +const importTwin = (input: { + readonly label: string; + readonly owner: V1Member; + readonly teammate: V1Member; + readonly former: string; + readonly toolkits: { readonly team: string; readonly mine: string }; + readonly into?: string; + readonly earlier: readonly string[]; +}) => + Effect.gen(function* () { + const emulators = yield* Emulators; + const operator = yield* v1ImportOperator; + const twin = yield* emulators.v1Workspace([input.owner.email, input.teammate.email]); + expect(twin.user).toBe(input.owner.user); + const organization = twin.organization; + yield* seedShared( + organization, + `v1-${randomUUID().slice(0, 8)}`, + { first: input.owner.user, second: input.teammate.user, former: input.former }, + input.toolkits, + ); + yield* allowV1Import(organization); + if (input.into !== undefined) yield* listV1Into(organization, input.into, "copy_into_empty"); + const run = v1RunId(input.label); + const started = yield* operator.start({ + mode: "import", + runId: run, + organizations: [organization], + }); + expect(started.status, JSON.stringify(started.body)).toBe(200); + const report = yield* operator.waitForReport(run, organization); + const toolkits = (report.items ?? []).filter((item) => item.kind === "toolkit"); + expect( + toolkits.map((item) => [item.label, item.status, item.reason ?? null]).toSorted(byJson), + ).toEqual([ + ["Mine", "imported", null], + ["Team kit", "imported", null], + ]); + expect(report.status, report.reason ?? "").toBe("imported"); + expect(report.verification).toMatchObject({ items: { failed: 0 }, passed: true }); + expect(report.cutover).toBe(true); + const target = report.v2Organization; + if (target === undefined) return yield* Effect.die("The import named no organization"); + expect(yield* movedFlag(organization)).toEqual([ + { state: "moved", v2_org_id: target.id, v2_org_slug: target.slug }, + ]); + + const v2Id = (member: V1Member) => + cloudRows(IdRow, `select id from "user" where lower(email) = lower($1)`, member.email).pipe( + Effect.map((users) => users[0]?.id), + ); + const owner = yield* v2Id(input.owner); + const teammate = yield* v2Id(input.teammate); + const copies = yield* liveCopies(target.id); + expect(copies.map((copy) => [copy.name, copy.userId]).toSorted(byJson)).toEqual( + [ + ["Mine", owner], + ["Team kit", owner], + ["Team kit", teammate], + ].toSorted(byJson), + ); + expect(copies.filter((copy) => input.earlier.includes(copy.id))).toEqual([]); + const mapped = yield* cloudRows( + Schema.Struct({ v2_id: Schema.String }), + `select v2_id from private_v1_import_map + where v1_org_id = $1 and v1_kind = 'toolkit' and status = 'imported'`, + organization, + ); + expect(mapped.map((row) => row.v2_id).toSorted()).toEqual( + copies.map((copy) => copy.id).toSorted(), + ); + + // v1 names each member's copy by the id it computes; the forward finds their live copy. + expect(toolkits.find((item) => item.label === "Team kit")?.id).toBe(input.toolkits.team); + for (const member of [input.owner, input.teammate]) + expect( + yield* forwardedToolkit({ + organization, + v2Organization: target.id, + toolkit: input.toolkits.team, + member: member.user, + }), + ).toBe(200); + + return { organization, report }; + }); + +/** Names longer than an app's address holds: it keeps the first 63 characters of a name's slug. */ +const longNames = { + integration: "Billing System (Account 1234567, read-only)", + connection: "personalBillingSystemAccount1234567ReadOnly", + artifact: "Billing reconciliation board for account 1234567, read-only weekly view", +}; + +/** + * A workspace of three members whose names are longer than an app's address holds. Its MCP server + * `longNames.integration` becomes an app per connection (decision 33): a workspace rule blocks a + * tool through the workspace's connection alone. Each member named a connection + * `longNames.connection`; the workspace's connection and one member's other connection add a digit + * to it. Its org artifacts are `artifacts`, by their place among six. Every row's id starts with + * `ids`, so another workspace seeded alike can take over this one's import map. + */ +const seedLongNames = ( + organization: string, + ids: string, + server: string, + members: readonly [string, string, string], + artifacts: readonly { readonly index: number; readonly title: string }[], +) => + Effect.gen(function* () { + const emulators = yield* Emulators; + const [first, second, third] = members; + const connections = [ + { owner: "org", subject: "", name: `${longNames.connection}2`, age: "5 days" }, + { owner: "user", subject: first, name: longNames.connection, age: "4 days" }, + { owner: "user", subject: second, name: longNames.connection, age: "3 days" }, + { owner: "user", subject: third, name: longNames.connection, age: "2 days" }, + { owner: "user", subject: first, name: `${longNames.connection}3`, age: "1 day" }, + ] as const; + const keyOf = (connection: (typeof connections)[number]) => + `connection:${connection.owner}:${server}:${connection.name}:token`; + for (const connection of connections) + yield* emulators.v1VaultObject( + `${connection.owner === "org" ? vaultScope(organization) : vaultScope(organization, connection.subject)}/secrets/${encodeURIComponent(keyOf(connection))}`, + `synthetic-${randomUUID().slice(0, 8)}`, + ); + const mcp = { + transport: "remote", + endpoint: "https://billing.example.com/mcp", + remoteTransport: "auto", + authenticationTemplate: [ + { slug: "header", kind: "apikey", placements: [{ carrier: "header", name: "X-Key" }] }, + ], + }; + yield* writeRows("v1-database.json", [ + row( + "insert into organizations (id, name, slug) values ($1, $2, $3)", + organization, + "Synthetic long names workspace", + `v1-${randomUUID().slice(0, 8)}`, + ), + row( + "insert into integration (tenant, row_id, slug, plugin_id, name, config) values ($1, $2, $3, 'mcp', $4, $5::json)", + organization, + `${ids}-server`, + server, + longNames.integration, + JSON.stringify(mcp), + ), + ...connections.flatMap((connection, index) => [ + row( + "insert into connection (tenant, row_id, owner, subject, integration, name, template, item_ids, created_at) values ($1, $2, $3, $4, $5, $6, 'header', $7::json, now() - $8::interval)", + organization, + `${ids}-connection-${index}`, + connection.owner, + connection.subject, + server, + connection.name, + JSON.stringify({ token: keyOf(connection) }), + connection.age, + ), + ...["search", "delete_item"].map((tool) => + row( + "insert into tool (tenant, owner, subject, integration, connection, plugin_id, name, annotations) values ($1, $2, $3, $4, $5, 'mcp', $6, $7::json)", + organization, + connection.owner, + connection.subject, + server, + connection.name, + tool, + JSON.stringify({ mcp: { toolName: tool } }), + ), + ), + ]), + row( + "insert into tool_policy (tenant, row_id, owner, subject, pattern, action, position) values ($1, $2, 'org', '', $3, 'block', 'b0')", + organization, + `${ids}-rule`, + `${server}.org.${longNames.connection}2.search`, + ), + ...artifacts.map(({ index, title }) => + row( + "insert into artifact (tenant, row_id, owner, subject, title, code, bindings, created_at) values ($1, $2, 'org', '', $3, 'export const App = () => null', '{}'::json, now() - $4::interval)", + organization, + `${ids}-artifact-${index}`, + title, + `${6 - index} minutes`, + ), + ), + ]); + }); + layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { + it.effect( + scenarios.v1ImportLongNames.title, + (context) => + withCase( + context, + Effect.gen(function* () { + const emulators = yield* Emulators; + const operator = yield* v1ImportOperator; + const emails = [ + syntheticEmail("long-first"), + syntheticEmail("long-second"), + syntheticEmail("long-third"), + ] as const; + const server = `billing_${randomUUID().slice(0, 6)}`; + // The first run imports a twin workspace; handing its import map to this one makes this + // one's import run again over what the first made, as a restarted import does. + const twin = yield* emulators.v1Workspace([...emails]); + const members = [ + twin.user, + yield* emulators.v1User(emails[1]), + yield* emulators.v1User(emails[2]), + ] as const; + const twinIds = `twin-${randomUUID().slice(0, 8)}`; + // Six artifacts share one long title. + yield* seedLongNames( + twin.organization, + twinIds, + server, + members, + Array.from({ length: 6 }, (_, index) => ({ index, title: longNames.artifact })), + ); + yield* allowV1Import(twin.organization); + + // Each app's name is shortened only as far as its address needs to hold its connection's + // name and number whole, so every app has an address of its own. + const shortened = `Billing System… (${longNames.connection}`; + const connectionApps = [ + [`${shortened})`, "billing-system-personalbillingsystemaccount1234567readonly"], + [`${shortened} 2)`, "billing-system-personalbillingsystemaccount1234567readonly-2"], + [`${shortened} 3)`, "billing-system-personalbillingsystemaccount1234567readonly-3"], + [`${shortened}2)`, "billing-system-personalbillingsystemaccount1234567readonly2"], + [`${shortened}3)`, "billing-system-personalbillingsystemaccount1234567readonly3"], + ]; + // Artifacts of one title are numbered by retries, each shortened so its number survives. + const artifactApps = [ + [longNames.artifact, "billing-reconciliation-board-for-account-1234567-read-only-week"], + ...[2, 3, 4, 5].map((attempt) => [ + `Billing reconciliation board for account 1234567, read-only… (v1 ${attempt})`, + `billing-reconciliation-board-for-account-1234567-read-only-v1-${attempt}`, + ]), + ]; + const at = (index: number) => (a: readonly string[], b: readonly string[]) => + (a[index] ?? "") < (b[index] ?? "") ? -1 : 1; + const appsOf = (report: V1Report) => + ( + report.splitIntegrations?.find((split) => split.label === longNames.integration) + ?.apps ?? [] + ).toSorted((a, b) => (a.name < b.name ? -1 : 1)); + const namesTaken = "Every name for this app is taken in the organization."; + const run = (mode: "dry-run" | "import", label: string, organization: string) => + Effect.gen(function* () { + const id = v1RunId(label); + const started = yield* operator.start({ + mode, + runId: id, + organizations: [organization], + }); + expect(started.status, JSON.stringify(started.body)).toBe(200); + return yield* operator.waitForReport(id, organization); + }); + + // The dry run plans the import's names, and finds that the last artifact of one title + // would have none left. + const planned = yield* run("dry-run", "long-dry", twin.organization); + expect(planned.integrationFailures).toEqual([]); + expect(appsOf(planned).map((app) => [app.name, app.status])).toEqual( + connectionApps.map(([name = ""]) => [name, "planned"]).toSorted(at(0)), + ); + expect( + planned.artifacts.flatMap((artifact) => + artifact.error === undefined ? [] : [[artifact.id, artifact.error]], + ), + ).toEqual([[`${twinIds}-artifact-5`, namesTaken]]); + expect(planned.status).toBe("blocked"); + + // The import makes every connection's app, and fails the artifact the dry run named. + const first = yield* run("import", "long", twin.organization); + expect(appsOf(first).map((app) => [app.name, app.status])).toEqual( + connectionApps.map(([name = ""]) => [name, "imported"]).toSorted(at(0)), + ); + const artifacts = (first.items ?? []).filter((item) => item.kind === "artifact"); + expect( + artifacts.map((item) => [item.id, item.status, item.reason ?? ""]).toSorted(at(0)), + ).toEqual([ + ...[0, 1, 2, 3, 4].map((index) => [`${twinIds}-artifact-${index}`, "imported", ""]), + [`${twinIds}-artifact-5`, "failed", namesTaken], + ]); + expect(first.status).toBe("failed"); + expect(first.cutover).not.toBe(true); + const target = first.v2Organization; + if (target === undefined) return yield* Effect.die("The import named no organization"); + const appRows = cloudRows( + Schema.Struct({ id: Schema.String, name: Schema.String, slug: Schema.String }), + `select id, name, slug from executor_apps where owner = $1 and name like 'Billing%'`, + `organization:${target.id}`, + ).pipe(Effect.map((apps) => apps.toSorted((a, b) => (a.slug < b.slug ? -1 : 1)))); + const before = yield* appRows; + expect(before.map((app) => [app.name, app.slug])).toEqual( + [...connectionApps, ...artifactApps].toSorted(at(1)), + ); + + // This workspace has the same server, connections and rule, and the artifact the import + // failed, which its member gave another title. It takes over the twin's import map, and + // its import keeps every app the first run made, saves that artifact and cuts over. The + // artifacts the first run saved are left out: saving one again commits to its source, + // which managed local Cloud cannot store; their apps stay as the first run made them. + const own = yield* emulators.v1Workspace([...emails]); + const ownIds = `own-${randomUUID().slice(0, 8)}`; + yield* seedLongNames(own.organization, ownIds, server, members, [ + { index: 5, title: "Billing reconciliation archive" }, + ]); + yield* allowV1Import(own.organization); + yield* writeRows("sso-database.json", [ + row( + "update private_v1_import_map set v1_id = replace(replace(v1_id, $1, $2), $3, $4), v1_org_id = $4 where v1_org_id = $3", + twinIds, + ownIds, + twin.organization, + own.organization, + ), + ]); + const second = yield* run("import", "long-again", own.organization); + expect( + second.status, + JSON.stringify((second.items ?? []).filter((item) => item.status === "failed")), + ).toBe("imported"); + expect(second.cutover).toBe(true); + expect(second.v2Organization).toEqual(target); + expect(appsOf(second).map((app) => [app.name, app.v2Id])).toEqual( + appsOf(first).map((app) => [app.name, app.v2Id]), + ); + const after = yield* appRows; + expect(after.filter((app) => app.name !== "Billing reconciliation archive")).toEqual( + before, + ); + expect(after.map((app) => app.name)).toContain("Billing reconciliation archive"); + expect(after).toHaveLength(before.length + 1); + }).pipe(Effect.provide(services)), + ), + // A dry run and two imports of a workspace whose five apps each build. + { timeout: 120_000 }, + ); + it.effect(scenarios.v1ImportSplitNames.title, (context) => withCase( context, @@ -707,6 +1409,145 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { ), ); + it.effect( + scenarios.v1ImportToolkitScale.title, + (context) => + withCase( + context, + Effect.gen(function* () { + const emulators = yield* Emulators; + const operator = yield* v1ImportOperator; + const emails = Array.from({ length: 20 }, (_, index) => syntheticEmail(`scale-${index}`)); + const [owner, ...others] = emails; + if (owner === undefined) return yield* Effect.die("The workspace has no members"); + const workspace = yield* emulators.v1Workspace([owner, ...others]); + const members = [workspace.user]; + for (const email of others) members.push(yield* emulators.v1User(email)); + const scale = toolkitScale(workspace.organization, members); + yield* scale.seed; + const copies = 8 * members.length + 2; + + // The dry run plans every member's copy of every toolkit in one step. + const dry = v1RunId("kits-dry"); + expect( + (yield* operator.start({ + mode: "dry-run", + runId: dry, + organizations: [workspace.organization], + })).status, + ).toBe(200); + const planned = yield* operator.waitForReport(dry, workspace.organization); + expect(planned.status, planned.reason ?? "").toBe("planned"); + expect(planned.counts.v1).toMatchObject({ + integrations: 8, + connections: scale.connections.length, + policies: 344, + toolkits: 10, + }); + // Only planned toolkits count their copies: one that failed or whose step gave up has + // none. + expect(planned.counts.v2).toMatchObject({ + apps: 7 + 3, + accounts: scale.connections.length, + mcpConnections: copies, + }); + expect(planned.integrationFailures).toEqual([]); + expect(planned.connectionFailures).toEqual([]); + expect( + (planned.splitIntegrations ?? []).map((split) => [ + split.label, + split.apps.map((app) => app.status), + ]), + ).toEqual([["Scale 0", ["planned", "planned", "planned"]]]); + + yield* allowV1Import(workspace.organization); + const run = v1RunId("kits"); + expect( + (yield* operator.start({ + mode: "import", + runId: run, + organizations: [workspace.organization], + })).status, + ).toBe(200); + const report = yield* operator.waitForReport(run, workspace.organization); + expect(report.status, report.reason ?? "").toBe("imported"); + expect(report.verification?.passed).toBe(true); + expect(report.verification?.items.failed).toBe(0); + const target = report.v2Organization; + if (target === undefined) return yield* Effect.die("The import named no organization"); + const items = report.items ?? []; + const toolkits = items.filter((item) => item.kind === "toolkit"); + expect(toolkits.map((item) => [item.label, item.status]).toSorted()).toEqual( + scale.kits.map((kit) => [kit.name, "imported"]).toSorted(), + ); + expect(report.counts.v2.mcpConnections).toBe(copies); + // A toolkit's note is its first copy's, which lists what the workspace blocks. + for (const kit of scale.kits.slice(0, 8)) + expect(toolkits.find((item) => item.label === kit.name)?.note).toBe( + scale.blockedNote(kit), + ); + + // Each server's app, each connection's own app on the first, and each account. + const apps = new Map(); + for (const item of items) + if (item.kind === "integration" && item.v2Id !== undefined) { + const server = scale.servers.find((candidate) => candidate.label === item.label); + if (server !== undefined) apps.set(server.slug, item.v2Id); + } + for (const split of report.splitIntegrations ?? []) + for (const app of split.apps) + if (app.v2Id !== undefined) apps.set(app.connection, app.v2Id); + expect(apps.size).toBe(7 + 3); + const accounts = new Map( + items.flatMap((item) => + item.kind === "connection" && item.v2Id !== undefined + ? [[item.id, item.v2Id] as const] + : [], + ), + ); + expect(accounts.size).toBe(scale.connections.length); + + // Every member has their own copy of each organization toolkit, granting exactly what + // its rules and the workspace allow; a member's own toolkit is theirs alone. + const users = yield* cloudRows( + Schema.Struct({ id: Schema.String, email: Schema.String }), + `select id, lower(email) as email from "user" + where lower(email) in (select lower(value) from json_array_elements_text($1::json))`, + JSON.stringify(emails), + ); + const v2Of = new Map( + emails.map((email, index) => [ + members[index] ?? "", + users.find((user) => user.email === email.toLowerCase())?.id ?? "", + ]), + ); + const saved = yield* cloudRows( + ToolkitCopy, + `select name, "userId", policy::text as policy from "mcpConnection" + where resource = $1 and not revoked`, + target.id, + ); + expect(saved).toHaveLength(copies); + for (const kit of scale.kits) { + const own = saved.filter((copy) => copy.name === kit.name); + expect(own.map((copy) => copy.userId).toSorted()).toEqual( + (kit.subject === "" ? members : [kit.subject]) + .map((member) => v2Of.get(member) ?? "") + .toSorted(), + ); + const expected = scale.expectedPolicy(kit, apps, accounts); + for (const copy of own) + expect( + copy.policy.apps.toSorted((a, b) => (a.app < b.app ? -1 : 1)), + `${kit.name} for ${copy.userId}`, + ).toEqual(expected); + } + }).pipe(Effect.provide(services)), + ), + // A dry run and an import of a workspace of 20 members. + { timeout: 240_000 }, + ); + it.effect(scenarios.v1ImportRollback.title, (context) => withCase( context, @@ -715,23 +1556,19 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { browser = yield* Browser; const operator = yield* v1ImportOperator; const identity = yield* emulators.identity("google"); - const workspace = yield* emulators.v1Workspace([identity.email]); - const slug = `v1-${randomUUID().slice(0, 8)}`; - yield* writeRows("v1-database.json", [ - row( - "insert into organizations (id, name, slug) values ($1, $2, $3)", - workspace.organization, - "Synthetic rollback workspace", - slug, - ), - // Some v1 data, so v1's sign-in check holds the member on v1: a rule of an integration - // v1 deleted, which has nothing to import. - row( - "insert into tool_policy (tenant, owner, subject, pattern, action, position) values ($1, 'org', '', $2, 'block', 'a0')", - workspace.organization, - `gone_${randomUUID().slice(0, 6)}.*.*.search`, - ), - ]); + // A workspace with a teammate and the shared seed's integration, connections, rules and + // toolkits; its data holds the member on v1 at sign-in. + const teammate = syntheticEmail("teammate"); + const workspace = yield* emulators.v1Workspace([identity.email, teammate]); + const teammateUser = yield* emulators.v1User(teammate); + const former = yield* emulators.v1Workspace([syntheticEmail("former")]); + const toolkits = { team: randomUUID(), mine: randomUUID() }; + yield* seedShared( + workspace.organization, + `v1-${randomUUID().slice(0, 8)}`, + { first: workspace.user, second: teammateUser, former: former.user }, + toolkits, + ); yield* signInOnV1(identity.email); yield* allowV1Import(workspace.organization); @@ -750,6 +1587,8 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { expect(yield* movedFlag(workspace.organization)).toEqual([ { state: "moved", v2_org_id: target.id, v2_org_slug: target.slug }, ]); + const copies = (yield* liveCopies(target.id)).map((copy) => copy.id); + expect(copies).toHaveLength(3); // While its v2 organization exists, its grants could renew beside v1's: no rollback. const refused = yield* operator.rollback(workspace.organization); @@ -789,6 +1628,117 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { workspace.organization, ), ).toEqual([{ v1_kind: "user" }]); + + // Imported again, it moves into a new v2 organization with every member's toolkit copy. + const again = yield* importTwin({ + label: "rollback-again", + owner: { user: workspace.user, email: identity.email }, + teammate: { user: teammateUser, email: teammate }, + former: former.user, + toolkits, + earlier: copies, + }); + expect(again.report.v2Organization?.id).not.toBe(target.id); + }).pipe(Effect.provide(services)), + ), + ); + + it.effect(scenarios.v1ImportRevokedRetry.title, (context) => + withCase( + context, + Effect.gen(function* () { + const emulators = yield* Emulators, + onboarding = yield* Onboarding; + const operator = yield* v1ImportOperator; + const identity = yield* onboarding.socialSignIn("google"); + const team = yield* onboarding.confirmTeam(yield* onboarding.prepareTeam); + const teammate = syntheticEmail("retry-teammate"); + const workspace = yield* emulators.v1Workspace([identity.email, teammate]); + const teammateUser = yield* emulators.v1User(teammate); + const former = yield* emulators.v1Workspace([syntheticEmail("retry-former")]); + const toolkits = { team: randomUUID(), mine: randomUUID() }; + const organization = workspace.organization; + yield* seedShared( + organization, + `v1-${randomUUID().slice(0, 8)}`, + { first: workspace.user, second: teammateUser, former: former.user }, + toolkits, + ); + yield* emulators.v1TeamSubscription(organization, { version: 3, seats: 2 }); + yield* allowV1Import(organization); + yield* listV1Into(organization, team.id, "copy_into_empty"); + const run = v1RunId("revoked-retry"); + expect( + (yield* operator.start({ mode: "import", runId: run, organizations: [organization] })) + .status, + ).toBe(200); + const report = yield* operator.waitForReport(run, organization); + expect(report.status, report.reason ?? "").toBe("imported"); + const first = (yield* liveCopies(team.id)).map((copy) => copy.id); + expect(first).toHaveLength(3); + expect((yield* operator.returnBilling(organization)).status).toBe(200); + const rolledBack = yield* operator.rollback(organization).pipe( + Effect.flatMap((response) => + response.status === 200 + ? Effect.succeed(response) + : Effect.fail(new Error(JSON.stringify(response.body))), + ), + Effect.retry(Schedule.spaced("1 second").pipe(Schedule.upTo({ duration: "50 seconds" }))), + ); + expect(rolledBack.status).toBe(200); + expect(yield* connectionsRevoked(first)).toEqual( + first.toSorted().map((id) => ({ id, revoked: true })), + ); + + // A failed retry of the re-import: generation 1 was saved, then a non-transient failure + // recorded the copy failed. The map row is what that failure leaves when it drops the id. + const twin = yield* emulators.v1Workspace([identity.email, teammate]); + expect(twin.user).toBe(workspace.user); + yield* seedShared( + twin.organization, + `v1-${randomUUID().slice(0, 8)}`, + { first: workspace.user, second: teammateUser, former: former.user }, + toolkits, + ); + yield* allowV1Import(twin.organization); + yield* listV1Into(twin.organization, team.id, "copy_into_empty"); + const copyKey = (toolkit: string, member: string) => `${toolkit}:${member}`; + for (const id of [ + copyKey(toolkits.team, workspace.user), + copyKey(toolkits.team, teammateUser), + copyKey(toolkits.mine, workspace.user), + ]) + yield* cloudRows( + Schema.Struct({ v1_id: Schema.String }), + `insert into private_v1_import_map + (v1_org_id, v1_kind, v1_id, status, reason, source_hash) + values ($1, 'toolkit', $2, 'failed', 'Its member is not imported.', 'failed-retry') + returning v1_id`, + twin.organization, + id, + ); + const again = v1RunId("revoked-again"); + const startedAgain = yield* operator.start({ + mode: "import", + runId: again, + organizations: [twin.organization], + }); + expect(startedAgain.status, JSON.stringify(startedAgain.body)).toBe(200); + const retried = yield* operator.waitForReport(again, twin.organization); + expect(retried.status, retried.reason ?? "").toBe("imported"); + const second = (yield* liveCopies(team.id)).map((copy) => copy.id); + expect(second).toHaveLength(3); + expect(second.filter((id) => first.includes(id))).toEqual([]); + expect(yield* connectionsRevoked(first)).toEqual( + first.toSorted().map((id) => ({ id, revoked: true })), + ); + const mapped = yield* cloudRows( + Schema.Struct({ v2_id: Schema.String }), + `select v2_id from private_v1_import_map + where v1_org_id = $1 and v1_kind = 'toolkit' and status = 'imported'`, + twin.organization, + ); + expect(mapped.map((row) => row.v2_id).toSorted()).toEqual(second.toSorted()); }).pipe(Effect.provide(services)), ), ); @@ -1652,12 +2602,15 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { const teammate = syntheticEmail("teammate"); const workspace = yield* emulators.v1Workspace([identity.email, teammate]); const organization = workspace.organization; + const teammateUser = yield* emulators.v1User(teammate); const former = yield* emulators.v1Workspace([syntheticEmail("former")]); - yield* seedShared(organization, `v1-${randomUUID().slice(0, 8)}`, { - first: workspace.user, - second: yield* emulators.v1User(teammate), - former: former.user, - }); + const toolkits = { team: randomUUID(), mine: randomUUID() }; + yield* seedShared( + organization, + `v1-${randomUUID().slice(0, 8)}`, + { first: workspace.user, second: teammateUser, former: former.user }, + toolkits, + ); const subscription = yield* emulators.v1TeamSubscription(organization, { version: 3, seats: 2, @@ -1741,6 +2694,8 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { connections: 3, members: 2, }); + const copies = (yield* liveCopies(team.id)).map((copy) => copy.id); + expect(copies).toHaveLength(3); // The subscription now bills the team, on v1's Stripe subscription. const customerId = yield* emulators.v2BillingCustomer(team.id); expect(yield* emulators.autumnStripeSubscriptions(customerId)).toEqual([ @@ -1801,6 +2756,262 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { team.id, ), ).toEqual([]); + // The rollback revoked the members' copies and kept them under their ids. + expect(yield* connectionsRevoked(copies)).toEqual( + copies.toSorted().map((id) => ({ id, revoked: true })), + ); + + // Imported again, the workspace copies into the team as before. Each member's toolkit copy + // is made again under a new id, and v1's toolkit URL forwards to it. + const again = yield* importTwin({ + label: "copy-again", + owner: { user: workspace.user, email: identity.email }, + teammate: { user: teammateUser, email: teammate }, + former: former.user, + toolkits, + into: team.id, + earlier: copies, + }); + expect(again.report.into).toEqual(into); + expect(again.report.v2Organization).toEqual({ id: team.id, slug: team.slug }); + expect(yield* contents).toEqual({ + ...before, + apps: before.apps + 1, + accounts: before.accounts + 3, + connections: 3, + members: 2, + }); + // The first import's copies stay revoked, with their grants and tokens. + expect(yield* connectionsRevoked(copies)).toEqual( + copies.toSorted().map((id) => ({ id, revoked: true })), + ); + }).pipe(Effect.provide(services)), + ), + ); + + it.effect(scenarios.v1ImportIntoEnterprise.title, (context) => + withCase( + context, + Effect.gen(function* () { + const emulators = yield* Emulators, + onboarding = yield* Onboarding; + const operator = yield* v1ImportOperator; + // The owner's v2 team is on v2's Enterprise plan, with a colleague already in it as a + // member, and nothing else set up. + const identity = yield* onboarding.socialSignIn("google"); + const team = yield* onboarding.confirmTeam(yield* onboarding.prepareTeam); + const owner = (yield* cloudRows( + IdRow, + `select id from "user" where email = $1`, + identity.email, + ))[0]?.id; + if (owner === undefined) return yield* Effect.die("The owner has no v2 user"); + const colleague = syntheticEmail("colleague"); + const colleagueUser = yield* v2User(colleague); + yield* writeRows("sso-database.json", [ + row( + `insert into member (id, "organizationId", "userId", role, "createdAt") values ($1, $2, $3, 'member', now())`, + randomUUID(), + team.id, + colleagueUser, + ), + ]); + const customerId = yield* emulators.v2BillingCustomer(team.id); + const catalog = customerId.slice(0, customerId.indexOf(":organization:")); + yield* emulators.billingSubscription({ + organizationId: team.id, + planId: `${catalog}-enterprise`, + status: "active", + }); + // Their v1 Enterprise workspace has the colleague, a newcomer and the shared seed's + // integration, connections, rules and toolkits. + const newcomer = syntheticEmail("newcomer"); + const workspace = yield* emulators.v1Workspace([identity.email, colleague, newcomer]); + const organization = workspace.organization; + const former = yield* emulators.v1Workspace([syntheticEmail("former")]); + yield* seedShared(organization, `v1-${randomUUID().slice(0, 8)}`, { + first: workspace.user, + second: yield* emulators.v1User(colleague), + former: former.user, + }); + yield* emulators.v1Plan(organization, "enterprise"); + yield* allowV1Import(organization); + yield* listV1Into(organization, team.id, "copy_into_empty"); + // Another Enterprise workspace of the owner is allowed but not listed into anything. + const unlisted = yield* emulators.v1Workspace([identity.email]); + yield* writeRows("v1-database.json", [ + row( + "insert into organizations (id, name, slug) values ($1, $2, $3)", + unlisted.organization, + "Synthetic unlisted workspace", + `v1-${randomUUID().slice(0, 8)}`, + ), + goneRule(unlisted.organization), + ]); + yield* emulators.v1Plan(unlisted.organization, "enterprise"); + yield* allowV1Import(unlisted.organization); + + const Contents = Schema.Struct({ + apps: Schema.Number, + accounts: Schema.Number, + connections: Schema.Number, + }); + /** What the team holds; accounts leave out the Executor keys v2 gives owners and admins. */ + const contents = cloudRows( + Contents, + `select + (select count(*)::int from executor_apps a where a.owner = 'organization:' || o.id) as apps, + (select count(*)::int from executor_accounts a where a.owner = 'organization:' || o.id + and not exists ( + select 1 from jsonb_each_text(coalesce(o.metadata::jsonb -> 'executorKeyAccounts', '{}'::jsonb)) k + where k.value = a.id + )) as accounts, + (select count(*)::int from "mcpConnection" c where c.resource = o.id and not c.revoked) as connections + from organization o where o.id = $1`, + team.id, + ).pipe(Effect.map((rows) => rows[0])); + const TeamMember = Schema.Struct({ userId: Schema.String, role: Schema.String }); + const teamMembers = cloudRows( + TeamMember, + `select "userId", role from member where "organizationId" = $1 order by "createdAt", "userId"`, + team.id, + ); + /** Each Autumn customer's plans and Stripe subscriptions, v2's team's and v1's own. */ + const plansOf = (id: string) => + Effect.map(emulators.autumnCustomer(id), (customer) => + customer === null + ? null + : customer.subscriptions + .map((plan) => ({ plan: plan.plan_id, status: plan.status })) + .toSorted((a, b) => a.plan.localeCompare(b.plan)), + ); + const billingOf = Effect.gen(function* () { + const team = yield* plansOf(customerId); + return { + team, + teamStripe: + team === null ? null : yield* emulators.autumnStripeSubscriptions(customerId), + v1: yield* plansOf(organization), + }; + }); + const holds = cloudRows( + IdRow, + "select organization_id as id from private_v1_import_billing where organization_id = $1", + team.id, + ); + const before = { + contents: yield* contents, + members: yield* teamMembers, + billing: yield* billingOf, + }; + if (before.contents === undefined) return yield* Effect.die("The team has no row"); + expect(before.contents).toMatchObject({ accounts: 0, connections: 0 }); + expect(before.members).toEqual([ + { userId: owner, role: "owner" }, + { userId: colleagueUser, role: "member" }, + ]); + expect(before.billing.team).toContainEqual({ + plan: `${catalog}-enterprise`, + status: "active", + }); + expect(before.billing.v1).toEqual([{ plan: "enterprise", status: "active" }]); + const into = { organization: team.id, mode: "copy_into_empty" }; + + const dry = v1RunId("enterprise-dry"); + expect( + (yield* operator.start({ mode: "dry-run", runId: dry, organizations: [organization] })) + .status, + ).toBe(200); + const planned = yield* operator.waitForReport(dry, organization); + expect(planned.status, planned.reason ?? "").toBe("planned"); + expect(planned.into).toEqual(into); + expect(planned.slug?.v2).toBe(team.slug); + // Nothing carries over, so the report has no billing. It still reports the SSO flag, which + // is null here: the emulated v1 WorkOS does not list SSO connections. + expect(planned.billing).toBeUndefined(); + expect(planned.flags).toEqual({ + sso: null, + overFreeMemberLimit: false, + paidPlans: ["enterprise"], + }); + + const run = v1RunId("enterprise"); + const started = yield* operator.start({ + mode: "import", + runId: run, + organizations: [unlisted.organization, organization], + }); + expect(started.status, JSON.stringify(started.body)).toBe(200); + // An Enterprise workspace the operator did not list is held back as before, and v1 has it + // back at once. + const held = yield* operator.waitForReport(run, unlisted.organization); + expect(held.status, held.reason ?? "").toBe("held_back"); + expect(held.reason).toBe( + "Enterprise organizations move by hand, after their v2 SSO is set up.", + ); + expect(held.into).toBeUndefined(); + expect(yield* movedFlag(unlisted.organization)).toEqual([ + { state: "rolled_back", v2_org_id: null, v2_org_slug: null }, + ]); + expect( + yield* cloudRows( + IdRow, + "select v1_id as id from private_v1_import_map where v1_org_id = $1", + unlisted.organization, + ), + ).toEqual([]); + + const report = yield* operator.waitForReport(run, organization); + expect(report.status, report.reason ?? "").toBe("imported"); + expect(report.into).toEqual(into); + expect(report.v2Organization).toEqual({ id: team.id, slug: team.slug }); + expect(report.cutover).toBe(true); + expect(report.verification).toMatchObject({ members: { planned: 3, inV2: 3 } }); + expect(report.billing).toBeUndefined(); + expect(report.flags).toMatchObject({ sso: null, paidPlans: ["enterprise"] }); + expect(yield* movedFlag(organization)).toEqual([ + { state: "moved", v2_org_id: team.id, v2_org_slug: team.slug }, + ]); + expect(yield* contents).toEqual({ + apps: before.contents.apps + 1, + accounts: 3, + connections: 4, + }); + // The newcomer joins with their v1 role; the colleague keeps their membership and role. + const newcomerUser = yield* cloudRows( + IdRow, + `select id from "user" where lower(email) = lower($1)`, + newcomer, + ); + expect(newcomerUser).toHaveLength(1); + expect(yield* teamMembers).toEqual([ + ...before.members, + { userId: newcomerUser[0]?.id, role: "admin" }, + ]); + const Membership = Schema.Struct({ v2_kind: Schema.NullOr(Schema.String) }); + expect( + yield* cloudRows( + Membership, + "select v2_kind from private_v1_import_map where v1_org_id = $1 and v1_kind = 'membership' order by v2_kind", + organization, + ), + ).toEqual([{ v2_kind: "existing_member" }, { v2_kind: "member" }]); + // The team's billing and v1's customer are as they were, and no hold was written. + expect(yield* billingOf).toEqual(before.billing); + expect(yield* holds).toEqual([]); + + // A rollback needs no billing return; it removes only what the import added. + const rolledBack = yield* operator.rollback(organization); + expect(rolledBack.status, JSON.stringify(rolledBack.body)).toBe(200); + expect(rolledBack.body).toEqual({ + organization, + state: "rolled_back", + removed: { apps: 1, accounts: 3, toolkitCopies: 4, members: 1 }, + }); + expect(yield* contents).toEqual(before.contents); + expect(yield* teamMembers).toEqual(before.members); + expect(yield* billingOf).toEqual(before.billing); + expect(yield* holds).toEqual([]); }).pipe(Effect.provide(services)), ), ); @@ -1877,6 +3088,11 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { yield* ownTeam(leaveOwner), "leave_v1_data", ); + // An Enterprise workspace moves only when listed to copy (decision 34). + const enterprise = yield* ownWorkspace("enterprise"); + yield* emulators.v1Plan(enterprise.organization, "enterprise"); + const leaveEnterprise = yield* ownWorkspace("leave-enterprise", "leave_v1_data"); + yield* emulators.v1Plan(leaveEnterprise.organization, "enterprise"); const unlisted = yield* ownWorkspace("mode", "everything"); const onlyOne = "only a workspace of one moves into an existing v2 organization without its data."; @@ -1890,6 +3106,8 @@ layer(TestLive, { excludeTestServices: true })("Cloud v1 import runs", (it) => { [missing, "nothing_to_copy", "The listed v2 organization does not exist."], [leavePaid, "leave_v1_data", "It has a paid v1 plan (team)."], [leaveShared, "leave_v1_data", `It has 2 active members; ${onlyOne}`], + [enterprise, "nothing_to_copy", "It has a paid v1 plan (enterprise)."], + [leaveEnterprise, "leave_v1_data", "It has a paid v1 plan (enterprise)."], [unlisted, null, "The allow table lists a mode the import does not know."], ] as const; const organizations = refusals.map(([workspace]) => workspace.organization); diff --git a/e2e/tests/credential-hosts.spec.ts b/e2e/tests/credential-hosts.spec.ts index e2faef1692..42ecc20ccf 100644 --- a/e2e/tests/credential-hosts.spec.ts +++ b/e2e/tests/credential-hosts.spec.ts @@ -672,6 +672,33 @@ layer(HostedLive, { excludeTestServices: true })("Credential hosts", (it) => { ), ); + // Docker names containers and services with underscores, such as `n8n_server_1`, and an app + // on the same network reaches the service by that name. `.localhost` names resolve to loopback. + it.effect(scenarios.credentialHostsUnderscore.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const { deploy, connect, call } = yield* scenario; + const upstream = yield* credentialUpstream; + const host = `n8n_server_1.localhost:${upstream.port}`; + const name = `Credential hosts ${randomUUID().slice(0, 8)}`; + const { path, app } = yield* deploy(name, credentialApp({ name, host, health: null })); + expect(app.requirements.accounts.service).toBeDefined(); + const values = synthetic(); + yield* connect(path, name, values); + const { token } = yield* call(path, Fields, "fields", {}); + expect(token).toMatch(handle); + + const sent = yield* call(path, Sent, "send", { url: `http://${host}/underscore` }); + expect(sent.status, sent.text).toBe(200); + const received = (yield* upstream.received).find( + (entry) => new URL(entry.url, upstream.origin).pathname === "/underscore", + ); + expect(received?.authorization).toBe(`Bearer ${values.token}`); + }), + ), + ); + it.effect(scenarios.credentialHostsRestart.title, (context) => withHostedCase( context, diff --git a/package.json b/package.json index c95da82e9e..dea351cbf0 100644 --- a/package.json +++ b/package.json @@ -173,17 +173,17 @@ }, "patchedDependencies": { "@cfworker/json-schema@4.1.1": "patches/@cfworker%2Fjson-schema@4.1.1.patch", - "@better-auth/oauth-provider@1.7.5": "patches/@better-auth%2Foauth-provider@1.7.5.patch", + "@better-auth/oauth-provider@1.7.16": "patches/@better-auth%2Foauth-provider@1.7.16.patch", "@cloudflare/worker-bundler@0.2.4": "patches/@cloudflare%2Fworker-bundler@0.2.4.patch", "miniflare@4.20260730.0": "patches/miniflare@4.20260730.0.patch", "tailwindcss-iso@1.0.7": "patches/tailwindcss-iso@1.0.7.patch", - "@better-auth/sso@1.7.5": "patches/@better-auth%2Fsso@1.7.5.patch", - "better-auth@1.7.5": "patches/better-auth@1.7.5.patch", + "@better-auth/sso@1.7.16": "patches/@better-auth%2Fsso@1.7.16.patch", + "better-auth@1.7.16": "patches/better-auth@1.7.16.patch", "@swagger-api/apidom-ns-openapi-3-1@1.11.6": "patches/@swagger-api%2Fapidom-ns-openapi-3-1@1.11.6.patch", "tree-sitter@0.21.1": "patches/tree-sitter@0.21.1.patch", "tree-sitter@0.22.4": "patches/tree-sitter@0.22.4.patch", "pg-cloudflare@1.4.0": "patches/pg-cloudflare@1.4.0.patch", - "@better-auth/passkey@1.7.5": "patches/@better-auth%2Fpasskey@1.7.5.patch", + "@better-auth/passkey@1.7.16": "patches/@better-auth%2Fpasskey@1.7.16.patch", "@electric-sql/pglite@0.5.8": "patches/@electric-sql%2Fpglite@0.5.8.patch", "effect@4.0.1": "patches/effect@4.0.1.patch", "@effect/platform-node-shared@4.0.1": "patches/@effect%2Fplatform-node-shared@4.0.1.patch", @@ -193,8 +193,7 @@ "alchemy@2.0.0-beta.80": "patches/alchemy@2.0.0-beta.80.patch", "@alchemy.run/cloudflare-runtime@2.0.0-beta.80": "patches/@alchemy.run%2Fcloudflare-runtime@2.0.0-beta.80.patch", "@distilled.cloud/cloudflare@1.0.0-rc.13": "patches/@distilled.cloud%2Fcloudflare@1.0.0-rc.13.patch", - "@better-auth/api-key@1.7.5": "patches/@better-auth%2Fapi-key@1.7.5.patch", - "@better-auth/core@1.7.5": "patches/@better-auth%2Fcore@1.7.5.patch" + "@better-auth/api-key@1.7.16": "patches/@better-auth%2Fapi-key@1.7.16.patch" }, "trustedDependencies": [ "dugite", diff --git a/packages/apps/package.json b/packages/apps/package.json index 0dbbabad3b..8e86e9cf19 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -1,6 +1,6 @@ { "name": "apps", - "version": "0.0.1-beta.119.shuv.1", + "version": "0.0.1-beta.120.shuv.1", "private": true, "description": "TypeScript apps with typed tools, accounts, storage, and UI.", "type": "module", diff --git a/packages/apps/protocols/14.json b/packages/apps/protocols/14.json new file mode 100644 index 0000000000..95a3603d25 --- /dev/null +++ b/packages/apps/protocols/14.json @@ -0,0 +1,2810 @@ +{ + "protocol": 14, + "previous": 13, + "messages": { + "invocation": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "command", + "type": { + "$message": "request" + }, + "isOptional": false + }, + { + "name": "accounts", + "type": { + "$message": "accounts" + }, + "isOptional": false + }, + { + "name": "approval", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "tool", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "input", + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + }, + "isOptional": false + } + ] + }, + "isOptional": true + }, + { + "name": "replay", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "key", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "fingerprint", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + }, + "isOptional": true + }, + { + "name": "deadline", + "type": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + } + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThanOrEqualTo", + "payload": { + "minimum": 0 + } + } + } + ] + }, + "isOptional": true + }, + { + "name": "workflowRun", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": true + }, + { + "name": "caller", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "kind", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "human" + }, + { + "_tag": "Literal", + "literal": "agent" + }, + { + "_tag": "Literal", + "literal": "system" + } + ] + }, + "isOptional": false + }, + { + "name": "subject", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": false + }, + { + "name": "issuer", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": false + }, + { + "name": "email", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": false + }, + { + "name": "organizationId", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": false + }, + { + "name": "channel", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "ui" + }, + { + "_tag": "Literal", + "literal": "mcp" + }, + { + "_tag": "Literal", + "literal": "api" + }, + { + "_tag": "Literal", + "literal": "dashboard" + }, + { + "_tag": "Literal", + "literal": "schedule" + }, + { + "_tag": "Literal", + "literal": "workflow" + }, + { + "_tag": "Literal", + "literal": "webhook" + } + ] + }, + "isOptional": false + }, + { + "name": "provenance", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "trusted", + "type": { + "_tag": "Literal", + "literal": false + }, + "isOptional": false + }, + { + "name": "clientId", + "type": { + "_tag": "String" + }, + "isOptional": true + }, + { + "name": "connectionId", + "type": { + "_tag": "String" + }, + "isOptional": true + } + ] + }, + "isOptional": true + } + ] + }, + "isOptional": true + }, + { + "name": "approvedBy", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "kind", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "human" + }, + { + "_tag": "Literal", + "literal": "agent" + }, + { + "_tag": "Literal", + "literal": "system" + } + ] + }, + "isOptional": false + }, + { + "name": "subject", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": false + }, + { + "name": "issuer", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": false + }, + { + "name": "email", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": false + }, + { + "name": "organizationId", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": false + }, + { + "name": "channel", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "ui" + }, + { + "_tag": "Literal", + "literal": "mcp" + }, + { + "_tag": "Literal", + "literal": "api" + }, + { + "_tag": "Literal", + "literal": "dashboard" + }, + { + "_tag": "Literal", + "literal": "schedule" + }, + { + "_tag": "Literal", + "literal": "workflow" + }, + { + "_tag": "Literal", + "literal": "webhook" + } + ] + }, + "isOptional": false + }, + { + "name": "provenance", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "trusted", + "type": { + "_tag": "Literal", + "literal": false + }, + "isOptional": false + }, + { + "name": "clientId", + "type": { + "_tag": "String" + }, + "isOptional": true + }, + { + "name": "connectionId", + "type": { + "_tag": "String" + }, + "isOptional": true + } + ] + }, + "isOptional": true + }, + { + "name": "method", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "model" + }, + { + "_tag": "Literal", + "literal": "client" + }, + { + "_tag": "Literal", + "literal": "native" + }, + { + "_tag": "Literal", + "literal": "browser" + }, + { + "_tag": "Literal", + "literal": "dashboard" + } + ] + }, + "isOptional": false + } + ] + }, + "isOptional": true + } + ] + }, + "requirements": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "capabilities", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "skills", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": false + }, + { + "name": "toolIndex", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": true + }, + { + "name": "skillSources", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": true + }, + { + "name": "scheduledTools", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": true + }, + { + "name": "sharedDefinitions", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": true + }, + { + "name": "callerIdentity", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": true + } + ] + }, + "isOptional": true + }, + { + "name": "database", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "fields", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "kind", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "string" + }, + { + "_tag": "Literal", + "literal": "number" + }, + { + "_tag": "Literal", + "literal": "boolean" + }, + { + "_tag": "Literal", + "literal": "id" + }, + { + "_tag": "Literal", + "literal": "userId" + } + ] + }, + "isOptional": false + }, + { + "name": "optional", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Boolean" + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": true + }, + { + "name": "default", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + } + } + ] + }, + { + "_tag": "Boolean" + } + ] + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": true + }, + { + "name": "references", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": true + } + ] + } + } + ] + }, + "isOptional": false + }, + { + "name": "indexes", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "name", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": false + }, + { + "name": "fields", + "type": { + "_tag": "Arrays", + "elements": [ + { + "isOptional": false, + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + } + } + ], + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + } + ] + }, + "isOptional": false + } + ] + } + ] + }, + "isOptional": false + } + ] + } + } + ] + }, + "isOptional": true + }, + { + "name": "accounts", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "app", + "type": { + "$shared": "3bf9db42acceec3a" + }, + "isOptional": false + }, + { + "name": "user", + "type": { + "$shared": "3bf9db42acceec3a" + }, + "isOptional": false + } + ] + }, + "isOptional": false + }, + { + "name": "sql", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": true + }, + { + "name": "events", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*)*$", + "flags": "u" + } + } + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 64 + } + } + } + ] + }, + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "description", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 1000 + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "filters", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "payload", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + } + } + ] + }, + "isOptional": false + } + ] + } + } + ] + }, + "isOptional": true + }, + { + "name": "icons", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "src", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^(?:data:image\\/(?:svg\\+xml|png|webp|jpeg);base64,[A-Za-z0-9+/]*={0,2}|https:\\/\\/\\S+)$", + "flags": "u" + } + } + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 32768 + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "mimeType", + "type": { + "_tag": "String" + }, + "isOptional": true + }, + { + "name": "sizes", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String" + } + ] + }, + "isOptional": true + }, + { + "name": "theme", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "light" + }, + { + "_tag": "Literal", + "literal": "dark" + } + ] + }, + "isOptional": true + } + ] + } + ], + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMaxLength", + "payload": { + "maxLength": 8 + } + } + }, + { + "_tag": "Filter", + "annotations": { + "message": "An app's icons total at most 32768 characters" + } + } + ] + }, + "isOptional": true + }, + { + "name": "sharedRuntime", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": true + } + ] + }, + "accounts": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "app", + "type": { + "$shared": "8f895899daebbf64" + }, + "isOptional": false + }, + { + "name": "user", + "type": { + "$shared": "8f895899daebbf64" + }, + "isOptional": true + } + ] + } + }, + "unchanged": [ + "request", + "response", + "catalog", + "catalogSummary", + "sharedCatalog", + "skills", + "elicitationRequest", + "elicitationReply", + "workflowStep", + "workflowStepReply", + "workflowControl", + "cacheCommand", + "cacheReply", + "accountCheck", + "migrate" + ], + "references": {}, + "shared": { + "0b0d667e3c209b0c": { + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "type": { + "$shared": "eb35fd8191fe5b27" + } + }, + "0ecd52d712f10938": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "type", + "type": { + "_tag": "Literal", + "literal": "oauth2" + }, + "isOptional": false + }, + { + "name": "scopeSeparator", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": true + }, + { + "name": "tokenRequestFormat", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "form" + }, + { + "_tag": "Literal", + "literal": "json" + } + ] + }, + "isOptional": true + }, + { + "name": "tokenResponse", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "path", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^[^.]+(\\.[^.]+)*$", + "flags": "" + } + } + } + ] + }, + "isOptional": false + } + ] + }, + "isOptional": true + }, + { + "name": "grant", + "type": { + "_tag": "Literal", + "literal": "authorization_code" + }, + "isOptional": true + }, + { + "name": "authorizationParams", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "String" + } + } + ], + "checks": [ + { + "_tag": "Filter", + "annotations": { + "message": "Authorization parameters cannot replace protocol parameters such as state or scope." + } + } + ] + }, + "isOptional": true + }, + { + "name": "tokenEndpointAuthMethod", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "none" + }, + { + "_tag": "Literal", + "literal": "client_secret_post" + }, + { + "_tag": "Literal", + "literal": "client_secret_basic" + }, + { + "_tag": "Literal", + "literal": "client_secret_basic_raw" + } + ] + }, + "isOptional": true + }, + { + "name": "resource", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": true + }, + { + "name": "authorizationUrl", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": false + }, + { + "name": "tokenUrl", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": false + }, + { + "name": "revocationUrl", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": true + }, + { + "name": "scopes", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String" + } + ] + }, + "isOptional": false + }, + { + "name": "issuer", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": true + }, + { + "name": "discover", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "authorizationServerMetadataUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "response", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "plain", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "name": "raw", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "$shared": "7fa3e0fa96a362fd" + } + ] + }, + "14bd4853b682e21b": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "type", + "type": { + "_tag": "Literal", + "literal": "oauth2" + }, + "isOptional": false + }, + { + "name": "grant", + "type": { + "_tag": "Literal", + "literal": "client_credentials" + }, + "isOptional": false + }, + { + "name": "tokenUrl", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": false + }, + { + "name": "revocationUrl", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": true + }, + { + "name": "scopes", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String" + } + ] + }, + "isOptional": false + }, + { + "name": "scopeSeparator", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": true + }, + { + "name": "tokenRequestFormat", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "form" + }, + { + "_tag": "Literal", + "literal": "json" + } + ] + }, + "isOptional": true + }, + { + "name": "tokenEndpointAuthMethod", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "client_secret_post" + }, + { + "_tag": "Literal", + "literal": "client_secret_basic" + }, + { + "_tag": "Literal", + "literal": "client_secret_basic_raw" + } + ] + }, + "isOptional": false + }, + { + "name": "resource", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": true + }, + { + "name": "authorizationUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "discover", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "authorizationServerMetadataUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "response", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "plain", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "name": "raw", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "$shared": "7fa3e0fa96a362fd" + } + ] + }, + "16c7d426e69479e6": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "name", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + }, + { + "$shared": "b7c44cc1d6ecfb85" + }, + { + "name": "hosts", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "Union", + "types": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^(\\*\\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*(?::[0-9]{1,5})?$", + "flags": "" + } + } + } + ] + }, + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^(\\*\\.)?[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?(?:\\.[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?)*(?::[0-9]{1,5})?$", + "flags": "" + } + } + } + ] + } + ] + } + ] + }, + "isOptional": true + } + ] + }, + "308a91c53c50f2fb": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "type", + "type": { + "_tag": "Literal", + "literal": "oauth2" + }, + "isOptional": false + }, + { + "name": "grant", + "type": { + "_tag": "Literal", + "literal": "client_credentials" + }, + "isOptional": false + }, + { + "name": "discover", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": false + }, + { + "name": "authorizationServerMetadataUrl", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": true + }, + { + "name": "authorizationUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "tokenUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "revocationUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "scopes", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String" + } + ] + }, + "isOptional": true + }, + { + "name": "scopeSeparator", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": true + }, + { + "name": "tokenRequestFormat", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "form" + }, + { + "_tag": "Literal", + "literal": "json" + } + ] + }, + "isOptional": true + }, + { + "name": "tokenEndpointAuthMethod", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "client_secret_post" + }, + { + "_tag": "Literal", + "literal": "client_secret_basic" + }, + { + "_tag": "Literal", + "literal": "client_secret_basic_raw" + } + ] + }, + "isOptional": false + }, + { + "name": "resource", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": true + }, + { + "name": "response", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "plain", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "name": "raw", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "$shared": "7fa3e0fa96a362fd" + } + ] + }, + "3634cfd2d0b776b7": { + "_tag": "Union", + "types": [ + { + "$shared": "6fe43caea1192cbc" + }, + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "in", + "type": { + "_tag": "Literal", + "literal": "query" + }, + "isOptional": false + }, + { + "name": "name", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "value", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "field", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + }, + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "base64", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "field", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + } + ] + } + ], + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + } + ] + } + ], + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + } + ] + }, + "3bf9db42acceec3a": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "$shared": "ec59a578c4f756be" + } + ] + }, + "4ded759dcdba2403": { + "_tag": "Arrays", + "rest": [ + { + "$shared": "3634cfd2d0b776b7" + } + ] + }, + "5524a0d67586f021": { + "_tag": "Objects", + "propertySignatures": [ + { + "$shared": "d1167380b135e5b7" + }, + { + "name": "cardinality", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "one" + }, + { + "_tag": "Literal", + "literal": "many" + } + ] + }, + "isOptional": false + }, + { + "name": "health", + "type": { + "_tag": "Literal", + "literal": true + }, + "isOptional": true + } + ] + }, + "6fe43caea1192cbc": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "in", + "type": { + "_tag": "Literal", + "literal": "header" + }, + "isOptional": false + }, + { + "name": "name", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^[!#$%&'*+.^_`|~0-9a-z-]+$", + "flags": "u" + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "value", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "field", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + }, + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "base64", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "Union", + "types": [ + { + "_tag": "String" + }, + { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "field", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + } + ] + } + ], + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + } + ] + } + ], + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + }, + "7219e966084f0ccc": { + "_tag": "Union", + "types": [ + { + "$shared": "8711fcb92cf01b4f" + }, + { + "$shared": "b76001a0bf1cec6d" + }, + { + "$shared": "0ecd52d712f10938" + }, + { + "$shared": "308a91c53c50f2fb" + }, + { + "$shared": "14bd4853b682e21b" + } + ] + }, + "7fa3e0fa96a362fd": { + "name": "request", + "type": { + "$shared": "4ded759dcdba2403" + }, + "isOptional": true + }, + "8711fcb92cf01b4f": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "type", + "type": { + "_tag": "Literal", + "literal": "secrets" + }, + "isOptional": false + }, + { + "name": "label", + "type": { + "_tag": "String" + }, + "isOptional": false + }, + { + "name": "fields", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "plain", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "name": "raw", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "$shared": "7fa3e0fa96a362fd" + } + ] + }, + "8f895899daebbf64": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "$shared": "0b0d667e3c209b0c" + } + ] + }, + "92f04f71889cd483": { + "name": "provider", + "type": { + "$shared": "16c7d426e69479e6" + }, + "isOptional": false + }, + "b76001a0bf1cec6d": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "type", + "type": { + "_tag": "Literal", + "literal": "oauth2" + }, + "isOptional": false + }, + { + "name": "scopeSeparator", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": true + }, + { + "name": "tokenRequestFormat", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "form" + }, + { + "_tag": "Literal", + "literal": "json" + } + ] + }, + "isOptional": true + }, + { + "name": "tokenResponse", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "path", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isPattern", + "payload": { + "source": "^[^.]+(\\.[^.]+)*$", + "flags": "" + } + } + } + ] + }, + "isOptional": false + } + ] + }, + "isOptional": true + }, + { + "name": "grant", + "type": { + "_tag": "Literal", + "literal": "authorization_code" + }, + "isOptional": true + }, + { + "name": "authorizationParams", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "String" + } + } + ], + "checks": [ + { + "_tag": "Filter", + "annotations": { + "message": "Authorization parameters cannot replace protocol parameters such as state or scope." + } + } + ] + }, + "isOptional": true + }, + { + "name": "tokenEndpointAuthMethod", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "Literal", + "literal": "none" + }, + { + "_tag": "Literal", + "literal": "client_secret_post" + }, + { + "_tag": "Literal", + "literal": "client_secret_basic" + }, + { + "_tag": "Literal", + "literal": "client_secret_basic_raw" + } + ] + }, + "isOptional": true + }, + { + "name": "resource", + "type": { + "_tag": "Union", + "types": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + { + "_tag": "Null" + } + ] + }, + "isOptional": true + }, + { + "name": "discover", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": false + }, + { + "name": "authorizationServerMetadataUrl", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter" + } + ] + }, + "isOptional": true + }, + { + "name": "authorizationUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "tokenUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "revocationUrl", + "type": { + "_tag": "Never" + }, + "isOptional": true + }, + { + "name": "scopes", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String" + } + ] + }, + "isOptional": true + }, + { + "name": "response", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "plain", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "name": "raw", + "type": { + "_tag": "Arrays", + "rest": [ + { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + } + ] + }, + "isOptional": true + }, + { + "$shared": "7fa3e0fa96a362fd" + } + ] + }, + "b7c44cc1d6ecfb85": { + "name": "auth", + "type": { + "$shared": "f1e4d4d57a79238b" + }, + "isOptional": false + }, + "d1167380b135e5b7": { + "name": "definition", + "type": { + "$shared": "16c7d426e69479e6" + }, + "isOptional": false + }, + "e595f516132f1469": { + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "type": { + "$shared": "7219e966084f0ccc" + } + }, + "e73afc906c84fa0a": { + "_tag": "Arrays", + "rest": [ + { + "$shared": "fc1adb7991b01447" + } + ] + }, + "eb35fd8191fe5b27": { + "_tag": "Union", + "types": [ + { + "$shared": "fc1adb7991b01447" + }, + { + "$shared": "e73afc906c84fa0a" + } + ] + }, + "ec59a578c4f756be": { + "parameter": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "type": { + "$shared": "5524a0d67586f021" + } + }, + "f1e4d4d57a79238b": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "$shared": "e595f516132f1469" + } + ] + }, + "fc1adb7991b01447": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "id", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isStartingWith", + "payload": { + "startsWith": "acc_" + } + } + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 5 + } + } + } + ] + }, + "isOptional": false + }, + { + "$shared": "92f04f71889cd483" + }, + { + "name": "method", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "generation", + "type": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isInt", + "payload": null + } + } + ] + }, + "isOptional": true + }, + { + "name": "fields", + "type": { + "_tag": "Objects", + "propertySignatures": [], + "indexSignatures": [ + { + "parameter": { + "_tag": "String" + }, + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + } + } + ] + }, + "isOptional": false + } + ] + } + } +} diff --git a/packages/apps/protocols/gates.json b/packages/apps/protocols/gates.json index 55e09f3e21..d2258cef89 100644 --- a/packages/apps/protocols/gates.json +++ b/packages/apps/protocols/gates.json @@ -19,6 +19,11 @@ "message": "accounts", "protocols": [6, 7, 8, 9, 10, 11, 12], "adapter": "Every adapter for protocols 1 to 12 sends a bundle its invocation's user accounts as the flat record it reads, in the invocation (whose accounts protocols 1 to 5 carry only there) and in workflow step contexts. Such a bundle declares no app requirements, so it has no app accounts." + }, + { + "message": "accounts", + "protocols": [13], + "adapter": "The protocol 13 adapter, which every earlier one shares, sends each account without its credential hosts that hold an underscore, which those bundles reject. A sealed handle keeps the hosts it was sealed for, and the outbound network checks those, so what the bundle reads changes nothing it does." } ], "additions": [ diff --git a/packages/apps/src/contracts/host.ts b/packages/apps/src/contracts/host.ts index df65caa65e..0cabf34d3f 100644 --- a/packages/apps/src/contracts/host.ts +++ b/packages/apps/src/contracts/host.ts @@ -87,8 +87,9 @@ export { protocol9 } from "./protocols/9.ts"; export { protocol10 } from "./protocols/10.ts"; export { protocol11 } from "./protocols/11.ts"; export { protocol12 } from "./protocols/12.ts"; +export { protocol13 } from "./protocols/13.ts"; export { - current as protocol13, + current as protocol14, AccountCheckCommand, CredentialHost, MigrateCommand, diff --git a/packages/apps/src/contracts/protocol-version.ts b/packages/apps/src/contracts/protocol-version.ts index f0ab1c6d9e..095656f48d 100644 --- a/packages/apps/src/contracts/protocol-version.ts +++ b/packages/apps/src/contracts/protocol-version.ts @@ -3,4 +3,4 @@ * snapshot. Its schemas are `protocols/current.ts`; released protocols are frozen. See notes/apps-publishing.md. * This file has no imports so the package build script can read it directly. */ -export const frameworkProtocol = 13; +export const frameworkProtocol = 14; diff --git a/packages/apps/src/contracts/protocols/13.ts b/packages/apps/src/contracts/protocols/13.ts new file mode 100644 index 0000000000..bb1338888f --- /dev/null +++ b/packages/apps/src/contracts/protocols/13.ts @@ -0,0 +1,400 @@ +/** + * Host protocol 13: protocol 12 plus account scopes, confirmed tool kinds and shared catalog + * definitions. + * + * Requirements and resolved accounts are nested by scope: `app` for accounts linked to the app for + * everyone, `user` for the caller's; a tool's schedule may run in the `app` scope. Tools may say their kind is confirmed (`kindConfirmed`). A + * build may declare `capabilities.sharedDefinitions`; the host then may ask an inspection for + * `definitions: "shared"`, answered with a shared catalog that sends each `$defs` once. + * + * The fork's protocol 13 also carries caller identity and shared runtimes. A build may declare + * `capabilities.callerIdentity` and then receive `caller` and `approvedBy`, and `sharedRuntime` + * when every caller shares one runtime. An approval failure may set `human`. Builds from before + * those fields omit them. Every other message is protocol 12's, re-exported unchanged. + * + * Released and frozen like the earlier ones: `bun run check` compares `protocol13` with + * `packages/apps/protocols/13.json`. The module imports only `effect` and earlier protocol modules, + * so no change elsewhere can alter it. Define the next protocol instead of editing this file. See + * notes/apps-publishing.md. + */ +import { Schema } from "effect"; +import { + AccountId, + ApprovalElicitation, + AppSkillName, + ElicitationFailed, + JsonObject, + JsonValue, + OpenapiResponseError, + OperationSchedule as PreviousSchedule, + ToolAnnotations, + WebhookCommand, + WorkflowCommand, + WorkflowReplay, + WorkflowRunId, +} from "./1.ts"; +import { DatabaseFieldReserved, DatabaseLimitExceeded, WorkflowFailure } from "./3.ts"; +import { + HostAccountsInvalid, + HostDeclarationInvalid, + HostEvaluationFailed, + HostInputInvalid, + HostKindMismatch, + HostOperationFailed, + HostOperationNotFound, + HostOutputInvalid, + HostRequestInvalid, + HostToolBlocked, + HostToolNotFound, + HostToolPolicyFailed, + ProviderError, +} from "./8.ts"; +import { McpError, SkillLoadFailed } from "./9.ts"; +import { EmittedEvents } from "./11.ts"; +import { + AccountCheckCommand, + DeclaredProvider, + DeclaredRequirements as PreviousRequirements, + HostRouterError, + InvocationDeadline, + MigrateCommand, + RouterIcon, + ToolKind, + TrustedToolApproval, + protocol12, +} from "./12.ts"; + +export * from "./12.ts"; + +/** One requirement: its provider, how many accounts it takes, and whether it has a check. */ +export const DeclaredAccountSlot = Schema.Struct({ + definition: DeclaredProvider, + cardinality: Schema.Literals(["one", "many"]), + health: Schema.optionalKey(Schema.Literal(true)), +}); +export type DeclaredAccountSlot = typeof DeclaredAccountSlot.Type; + +/** One scope's requirements, by name. */ +export const DeclaredAccountSlots = Schema.Record(Schema.NonEmptyString, DeclaredAccountSlot); +export type DeclaredAccountSlots = typeof DeclaredAccountSlots.Type; + +/** + * Protocol 12's requirements, nested by scope, with the sharedDefinitions capability. A name is + * used in one scope only. + */ +export const DeclaredRequirements = Schema.Struct({ + capabilities: Schema.optionalKey( + Schema.Struct({ + skills: Schema.Literal(true), + toolIndex: Schema.optionalKey(Schema.Literal(true)), + skillSources: Schema.optionalKey(Schema.Literal(true)), + scheduledTools: Schema.optionalKey(Schema.Literal(true)), + sharedDefinitions: Schema.optionalKey(Schema.Literal(true)), + /** + * Accepts the invocation's verified `caller` and, on a resumed approval, `approvedBy`. + * Earlier builds are never sent either. + */ + callerIdentity: Schema.optionalKey(Schema.Literal(true)), + }), + ), + database: PreviousRequirements.fields.database, + accounts: Schema.Struct({ app: DeclaredAccountSlots, user: DeclaredAccountSlots }), + sql: PreviousRequirements.fields.sql, + events: PreviousRequirements.fields.events, + icons: PreviousRequirements.fields.icons, + /** + * The author declared that every caller may share one runtime: hosts name the app's Worker and + * data facet by build alone, not by the caller's account selection. Hosts that predate it, and + * builds that do not declare it, keep one runtime per selection. + */ + sharedRuntime: Schema.optionalKey(Schema.Literal(true)), +}); +export type DeclaredRequirements = typeof DeclaredRequirements.Type; + +/** Host-resolved credentials for one stable saved account. */ +export const ResolvedAccount = Schema.Struct({ + id: AccountId, + provider: DeclaredProvider, + method: Schema.NonEmptyString, + generation: Schema.optionalKey(Schema.Int), + fields: JsonObject, +}); +export type ResolvedAccount = typeof ResolvedAccount.Type; + +/** One scope's resolved accounts by requirement; [] differs from a missing slot. */ +export const ResolvedAccountSlots = Schema.Record( + Schema.NonEmptyString, + Schema.Union([ResolvedAccount, Schema.Array(ResolvedAccount)]), +); +export type ResolvedAccountSlots = typeof ResolvedAccountSlots.Type; + +/** An invocation's accounts by scope. `user` is absent for work without a caller. */ +export const ResolvedAccounts = Schema.Struct({ + app: ResolvedAccountSlots, + user: Schema.optionalKey(ResolvedAccountSlots), +}); +export type ResolvedAccounts = typeof ResolvedAccounts.Type; + +/** + * What started an invocation: a person, an agent acting for one, or the host's own background work. + * Agents are MCP clients and API keys; they act for the person who owns the grant or key. + */ +export const CallerKind = Schema.Literals(["human", "agent", "system"]); +export type CallerKind = typeof CallerKind.Type; + +/** The surface an invocation arrived through. Every invocation names one. */ +export const CallerChannel = Schema.Literals([ + "ui", + "mcp", + "api", + "dashboard", + "schedule", + "workflow", + "webhook", +]); +export type CallerChannel = typeof CallerChannel.Type; + +/** + * Unverified client details, for logs only. The host relays what the client presented; never + * authorize anything with them. + */ +export const CallerProvenance = Schema.Struct({ + trusted: Schema.Literal(false), + /** The MCP OAuth client, or `pat:` for a personal access token. */ + clientId: Schema.optionalKey(Schema.String), + /** The scoped MCP connection the grant was issued for. */ + connectionId: Schema.optionalKey(Schema.String), +}); +export type CallerProvenance = typeof CallerProvenance.Type; + +/** + * The verified caller of one invocation. `subject` is the user the invocation acts for, or null + * for background work that has none. `issuer` is the authority that verified it. + */ +export const Caller = Schema.Struct({ + kind: CallerKind, + subject: Schema.NullOr(Schema.String), + issuer: Schema.NullOr(Schema.String), + email: Schema.NullOr(Schema.String), + organizationId: Schema.NullOr(Schema.String), + channel: CallerChannel, + provenance: Schema.optionalKey(CallerProvenance), +}); +export type Caller = typeof Caller.Type; + +/** How a person or agent answered an approval request. */ +export const ApprovalMethod = Schema.Literals([ + "model", + "client", + "native", + "browser", + "dashboard", +]); +export type ApprovalMethod = typeof ApprovalMethod.Type; + +/** + * Who answered the approval a resumed call runs under. `caller` stays the original requester; + * `approvedBy` has its own channel, such as a browser approval of an MCP call. + */ +export const ApprovedBy = Schema.Struct({ ...Caller.fields, method: ApprovalMethod }); +export type ApprovedBy = typeof ApprovedBy.Type; + +/** Protocol 12's operation schedule; an app's schedule may run with its app accounts. */ +export const OperationSchedule = Schema.Struct({ + name: PreviousSchedule.fields.name, + scope: Schema.optionalKey(Schema.Literal("app")), + timing: PreviousSchedule.fields.timing, + input: PreviousSchedule.fields.input, +}); +export type OperationSchedule = typeof OperationSchedule.Type; + +/** + * Protocol 12's tool description. A tool may say its kind is confirmed, and its schedules may + * name the app scope. + */ +export const HostedTool = Schema.Struct({ + schedules: Schema.optionalKey(Schema.Array(OperationSchedule)), + name: Schema.NonEmptyString, + router: Schema.optionalKey(Schema.NonEmptyString), + tags: Schema.optionalKey(Schema.Array(Schema.NonEmptyString)), + description: Schema.String, + inputSchema: JsonObject, + readOnly: Schema.optionalKey(Schema.Boolean), + kindConfirmed: Schema.optionalKey(Schema.Boolean), + title: Schema.optionalKey(Schema.String), + outputSchema: Schema.optionalKey(JsonObject), + annotations: Schema.optionalKey(ToolAnnotations), + _meta: Schema.optionalKey(JsonObject), +}); +export type HostedTool = typeof HostedTool.Type; + +/** Catalog entry without schemas. */ +export const HostedToolSummary = HostedTool.mapFields( + ({ inputSchema: _input, outputSchema: _output, _meta, ...fields }) => fields, +); +export type HostedToolSummary = typeof HostedToolSummary.Type; + +/** One router in a live catalog. */ +export const HostedRouter = Schema.Struct({ + path: Schema.String, + title: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), + icons: Schema.optionalKey(Schema.Array(RouterIcon)), + tags: Schema.optionalKey(Schema.Record(Schema.NonEmptyString, Schema.String)), + skill: Schema.optionalKey(AppSkillName), + error: Schema.optionalKey(HostRouterError), +}); +export type HostedRouter = typeof HostedRouter.Type; + +/** A live evaluation's tools and the routers that group them. */ +export const HostedCatalog = Schema.Struct({ + tools: Schema.Array(HostedTool), + routers: Schema.Array(HostedRouter), +}); +export type HostedCatalog = typeof HostedCatalog.Type; + +/** A catalog without tool schemas. */ +export const HostedCatalogSummary = Schema.Struct({ + tools: Schema.Array(HostedToolSummary), + routers: Schema.Array(HostedRouter), +}); +export type HostedCatalogSummary = typeof HostedCatalogSummary.Type; + +/** A tool schema whose `$defs` a shared catalog sends once, beside its tools. */ +export const SharedSchemaKey = Schema.Literals(["inputSchema", "outputSchema"]); +export type SharedSchemaKey = typeof SharedSchemaKey.Type; + +/** A tool of a shared catalog; `shared` names the schemas whose `$defs` the catalog holds. */ +export const HostedSharedTool = Schema.Struct({ + ...HostedTool.fields, + shared: Schema.optionalKey(Schema.Array(SharedSchemaKey)), +}); +export type HostedSharedTool = typeof HostedSharedTool.Type; + +/** A catalog that sends each `$defs` its tools' schemas hold once, and each definition once. */ +export const HostedSharedCatalog = Schema.Struct({ + tools: Schema.Array(HostedSharedTool), + routers: Schema.Array(HostedRouter), + definitions: Schema.Array(JsonValue), + defs: Schema.Array(Schema.Record(Schema.String, Schema.Int)), +}); +export type HostedSharedCatalog = typeof HostedSharedCatalog.Type; + +/** Protocol 12's commands; an inspection may ask for shared definitions. */ +export const HostRequest = Schema.Union([ + WorkflowCommand, + WebhookCommand, + Schema.Struct({ operation: Schema.Literal("requirements") }), + Schema.Struct({ + operation: Schema.Literal("inspect"), + detail: Schema.optionalKey(Schema.Literal("summary")), + tools: Schema.optionalKey(Schema.Array(Schema.NonEmptyString)), + scheduled: Schema.optionalKey(Schema.Literal(true)), + definitions: Schema.optionalKey(Schema.Literal("shared")), + }), + Schema.Struct({ + operation: Schema.Literal("skills"), + sources: Schema.optionalKey(Schema.Literal(true)), + }), + Schema.Struct({ + operation: Schema.Literal("query"), + name: Schema.NonEmptyString, + input: JsonValue, + }), + Schema.Struct({ + operation: Schema.Literal("mutate"), + name: Schema.NonEmptyString, + input: JsonValue, + }), + Schema.Struct({ + operation: Schema.Literal("call"), + tool: Schema.NonEmptyString, + kind: Schema.optionalKey(ToolKind), + input: JsonValue, + }), + AccountCheckCommand, + MigrateCommand, +]); +export type HostRequest = typeof HostRequest.Type; + +/** + * Protocol 12's approval failure, plus `human` when a person has to approve. An agent's own answer + * never counts. Older bundles omit it. + */ +export class HostToolApprovalRequired extends Schema.TaggedError()( + "HostToolApprovalRequired", + { + input: JsonValue, + elicitation: ApprovalElicitation, + human: Schema.optionalKey(Schema.Literal(true)), + }, +) {} + +/** Protocol 9's error envelope, with this protocol's approval failure. */ +export const HostError = Schema.Union([ + OpenapiResponseError, + ProviderError, + McpError, + SkillLoadFailed, + WorkflowFailure, + HostRequestInvalid, + HostAccountsInvalid, + HostDeclarationInvalid, + HostEvaluationFailed, + HostOperationNotFound, + HostOperationFailed, + DatabaseLimitExceeded, + DatabaseFieldReserved, + HostToolNotFound, + HostInputInvalid, + HostOutputInvalid, + HostToolBlocked, + HostToolApprovalRequired, + HostToolPolicyFailed, + ElicitationFailed, + HostKindMismatch, +]); +export type HostError = typeof HostError.Type; + +/** Protocol 11's response envelope, carrying this protocol's errors. */ +export const HostResponse = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + value: JsonValue, + toolError: Schema.optionalKey(Schema.Literal(true)), + events: Schema.optionalKey(EmittedEvents), + }), + Schema.Struct({ ok: Schema.Literal(false), error: HostError }), +]); +export type HostResponse = typeof HostResponse.Type; + +/** The JSON body the host sends to a bundle's generated server entry. */ +export const HostInvocation = Schema.Struct({ + command: HostRequest, + accounts: ResolvedAccounts, + approval: Schema.optionalKey(TrustedToolApproval), + replay: Schema.optionalKey(WorkflowReplay), + deadline: Schema.optionalKey(InvocationDeadline), + workflowRun: Schema.optionalKey(WorkflowRunId), + /** Sent only to builds that declare the callerIdentity capability. */ + caller: Schema.optionalKey(Caller), + /** On a resumed approval, to builds that declare callerIdentity. */ + approvedBy: Schema.optionalKey(ApprovedBy), +}); +export type HostInvocation = typeof HostInvocation.Type; + +/** Every message of protocol 13, in the order its snapshot records them. */ +export const protocol13 = { + version: 13, + schemas: { + ...protocol12.schemas, + invocation: HostInvocation, + request: HostRequest, + response: HostResponse, + requirements: DeclaredRequirements, + catalog: HostedCatalog, + catalogSummary: HostedCatalogSummary, + sharedCatalog: HostedSharedCatalog, + accounts: ResolvedAccounts, + }, +} as const; diff --git a/packages/apps/src/contracts/protocols/current.ts b/packages/apps/src/contracts/protocols/current.ts index c27bec7799..0d4e17ad51 100644 --- a/packages/apps/src/contracts/protocols/current.ts +++ b/packages/apps/src/contracts/protocols/current.ts @@ -89,13 +89,21 @@ export class SkillLoadFailed extends Schema.TaggedError()("Skil /** * Where a provider's credentials may be sent: an exact host, `host:port`, or `*.` followed by a - * domain, matching exactly one more label. Lowercase, without a scheme or path. + * domain, matching exactly one more label. Lowercase, without a scheme or path. Labels may hold + * underscores, as Docker container and service names do (`n8n_server_1:5678`); URLs keep them. */ -export const CredentialHost = Schema.String.check( - Schema.isPattern( - /^(\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*(?::[0-9]{1,5})?$/, +export const CredentialHost = Schema.Union([ + Schema.String.check( + Schema.isPattern( + /^(\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*(?::[0-9]{1,5})?$/, + ), ), -); + Schema.String.check( + Schema.isPattern( + /^(\*\.)?[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?(?:\.[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?)*(?::[0-9]{1,5})?$/, + ), + ), +]); /** * Field names an auth method exposes to app code, and where its secret fields may be sent. Every diff --git a/packages/apps/src/contracts/protocols/released.ts b/packages/apps/src/contracts/protocols/released.ts index e2368feb16..0c5187ac1e 100644 --- a/packages/apps/src/contracts/protocols/released.ts +++ b/packages/apps/src/contracts/protocols/released.ts @@ -15,6 +15,7 @@ import { protocol9 } from "./9.ts"; import { protocol10 } from "./10.ts"; import { protocol11 } from "./11.ts"; import { protocol12 } from "./12.ts"; +import { protocol13 } from "./13.ts"; export const releasedProtocols = [ protocol1, @@ -29,4 +30,5 @@ export const releasedProtocols = [ protocol10, protocol11, protocol12, + protocol13, ] as const; diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 1a3d2a204a..bea86440fd 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -10,7 +10,7 @@ "@effect/atom-react": "4.0.1", "@executor-js/ui": "workspace:*", "@executor-js/utils": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "radix-ui": "1.6.7" }, diff --git a/packages/mcp-auth/package.json b/packages/mcp-auth/package.json index b1be411d41..d15c77144a 100644 --- a/packages/mcp-auth/package.json +++ b/packages/mcp-auth/package.json @@ -13,14 +13,14 @@ "./node-database": "./src/implementation/node-database.ts" }, "dependencies": { - "@better-auth/core": "1.7.5", - "@better-auth/oauth-provider": "1.7.5", + "@better-auth/core": "1.7.16", + "@better-auth/oauth-provider": "1.7.16", "@effect/sql-pglite": "4.0.1", "@executor-js/authorization": "workspace:*", "@executor-js/mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "@executor-js/utils": "workspace:*", - "better-auth": "1.7.5", + "better-auth": "1.7.16", "effect": "4.0.1", "kysely": "0.29.6" } diff --git a/packages/sdk/src/contracts/app-slug.ts b/packages/sdk/src/contracts/app-slug.ts index fe84398634..f83c5632f3 100644 --- a/packages/sdk/src/contracts/app-slug.ts +++ b/packages/sdk/src/contracts/app-slug.ts @@ -10,16 +10,30 @@ export const AppSlug = Schema.String.check( /** Validated configured-app address. */ export type AppSlug = typeof AppSlug.Type; -/** Derive a DNS-safe address without consulting other apps or allocating suffixes. */ -export const appSlug = (name: string): AppSlug => { +/** The longest address: a DNS label. */ +const maxLength = 63; + +/** A name's whole address, before it is cut to `maxLength`. */ +const label = (name: string) => { const normalized = name .normalize("NFKD") .toLowerCase() - .replace(/[\u0300-\u036f]/g, "") + .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); - const label = - normalized.length === 0 ? "app" : reserved.has(normalized) ? `app-${normalized}` : normalized; - const base = label.slice(0, 63).replace(/-+$/g, ""); - return Schema.decodeUnknownSync(AppSlug)(base); + return normalized.length === 0 + ? "app" + : reserved.has(normalized) + ? `app-${normalized}` + : normalized; }; + +/** Derive a DNS-safe address without consulting other apps or allocating suffixes. */ +export const appSlug = (name: string): AppSlug => + Schema.decodeUnknownSync(AppSlug)(label(name).slice(0, maxLength).replace(/-+$/g, "")); + +/** + * Whether `name`'s address holds all of it. A longer name's address is cut short, so names that + * differ only after the cut, such as by a suffix that numbers them, share one address. + */ +export const appSlugFits = (name: string) => label(name).length <= maxLength; diff --git a/packages/sdk/src/contracts/apps.ts b/packages/sdk/src/contracts/apps.ts index f7567bd91e..3c653d7a9e 100644 --- a/packages/sdk/src/contracts/apps.ts +++ b/packages/sdk/src/contracts/apps.ts @@ -3,7 +3,7 @@ import { ApiError } from "@executor-js/utils/api-error"; import { withLegacyKeys } from "@executor-js/utils/legacy-keys"; import { DeclaredRequirements } from "apps/contracts"; import { AppSlug } from "./app-slug.ts"; -export { AppSlug, appSlug } from "./app-slug.ts"; +export { AppSlug, appSlug, appSlugFits } from "./app-slug.ts"; /** Apps own deployed code and declared requirements. Callers choose accounts for each operation. */ import { Effect, Schema } from "effect"; import { StorageError } from "./shared.ts"; diff --git a/packages/sdk/src/implementation/app-protocols.ts b/packages/sdk/src/implementation/app-protocols.ts index d63bcdba56..499524ac16 100644 --- a/packages/sdk/src/implementation/app-protocols.ts +++ b/packages/sdk/src/implementation/app-protocols.ts @@ -14,6 +14,9 @@ import { type HostAccounts, type HostInvocation, type HostRequest, + type ResolvedAccount, + type ResolvedAccounts, + type ResolvedAccountSlots, type WorkflowExecution, } from "apps/contracts"; import type { SourceTextFile as SourceFile } from "../contracts/source.ts"; @@ -55,13 +58,18 @@ export interface AppProtocol { * account was connected through a later app that declared hosts. */ readonly sealedCredentials: boolean; + /** + * The accounts, already sealed or unsealed, as this protocol's bundles read them, in the + * invocation and in each workflow step's context. + */ + readonly accounts: (accounts: ResolvedAccounts) => ResolvedAccounts; } -/** Protocol 13 is the host's current protocol, so its messages need no conversion. */ -const protocol13: AppProtocol = { - version: 13, +/** Protocol 14 is the host's current protocol, so its messages need no conversion. */ +const protocol14: AppProtocol = { + version: 14, workerEntry: appBridge, - nodeEntry: nodeAppEntry(13), + nodeEntry: nodeAppEntry(14), invocation: (input) => JSON.stringify(input), request: (command) => command, refuse: () => undefined, @@ -69,6 +77,48 @@ const protocol13: AppProtocol = { workflow: (execution) => execution, concurrentData: true, sealedCredentials: true, + accounts: (accounts) => accounts, +}; + +/** A credential host with an underscore, which bundles of protocol 13 and earlier reject. */ +const underscored = (host: string) => host.includes("_"); + +const withoutUnderscoredHosts = (account: ResolvedAccount): ResolvedAccount => { + const hosts = account.provider.hosts; + return hosts?.some(underscored) === true + ? { ...account, provider: { ...account.provider, hosts: hosts.filter((h) => !underscored(h)) } } + : account; +}; + +const slotsWithoutUnderscoredHosts = (slots: ResolvedAccountSlots): ResolvedAccountSlots => + Object.fromEntries( + Object.entries(slots).map(([slot, selected]) => [ + slot, + "provider" in selected + ? withoutUnderscoredHosts(selected) + : selected.map(withoutUnderscoredHosts), + ]), + ); + +/** + * Protocol 13 is protocol 14 with credential hosts that never hold an underscore. An account its + * bundle uses can still carry one: connected through a later app that declared it, and granted by + * this app's wildcard. Its bundles read each account's hosts but never decide from them where a + * credential goes: a sealed handle carries the hosts it was sealed for and the outbound network + * checks those. So such a bundle receives each account without its underscored hosts, which it + * would otherwise reject with the whole invocation. Every other message and reply is unchanged. + */ +const protocol13: AppProtocol = { + ...protocol14, + version: 13, + nodeEntry: nodeAppEntry(13), + accounts: (accounts) => + accounts.user === undefined + ? { app: slotsWithoutUnderscoredHosts(accounts.app) } + : { + app: slotsWithoutUnderscoredHosts(accounts.app), + user: slotsWithoutUnderscoredHosts(accounts.user), + }, }; /** @@ -254,6 +304,7 @@ const legacyProtocol = (version: LegacyVersion): AppProtocol => { }), concurrentData: false, sealedCredentials: false, + accounts: protocol13.accounts, }; }; @@ -272,6 +323,7 @@ const protocols: ReadonlyMap = new Map( protocol11, protocol12, protocol13, + protocol14, ].map((protocol) => [protocol.version, protocol]), ); diff --git a/packages/sdk/src/implementation/app-runner.ts b/packages/sdk/src/implementation/app-runner.ts index 3f511f9348..90c1ee065e 100644 --- a/packages/sdk/src/implementation/app-runner.ts +++ b/packages/sdk/src/implementation/app-runner.ts @@ -429,9 +429,10 @@ const bundleAccounts = ( key: Effect.Effect, accounts: HostAccounts, ): Effect.Effect => { - if (protocol.sealedCredentials) return sealAccounts(accounts, { app, key }); + if (protocol.sealedCredentials) + return sealAccounts(accounts, { app, key }).pipe(Effect.map(protocol.accounts)); const unsealed = unsealedAccounts(accounts); - return unsealed === undefined ? failed("managed") : Effect.succeed(unsealed); + return unsealed === undefined ? failed("managed") : Effect.succeed(protocol.accounts(unsealed)); }; /** diff --git a/patches/@better-auth%2Fapi-key@1.7.5.patch b/patches/@better-auth%2Fapi-key@1.7.16.patch similarity index 80% rename from patches/@better-auth%2Fapi-key@1.7.5.patch rename to patches/@better-auth%2Fapi-key@1.7.16.patch index d2d18b58cc..055542c549 100644 --- a/patches/@better-auth%2Fapi-key@1.7.5.patch +++ b/patches/@better-auth%2Fapi-key@1.7.16.patch @@ -1,8 +1,8 @@ diff --git a/dist/index.mjs b/dist/index.mjs -index bf85c8b78915bee1252a41420a742b306ed3bcdd..661dc2537968058c46af712b42e897b895ea1793 100644 +index 65f487a..75074c9 100644 --- a/dist/index.mjs +++ b/dist/index.mjs -@@ -1986,8 +1986,11 @@ function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) { +@@ -1988,8 +1988,11 @@ function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) { ctx.context.logger.error("Failed to delete expired API keys:", err); })); } catch (error) { @@ -15,7 +15,7 @@ index bf85c8b78915bee1252a41420a742b306ed3bcdd..661dc2537968058c46af712b42e897b8 valid: false, error: { ...error.body, -@@ -1996,14 +1999,6 @@ function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) { +@@ -1998,14 +2001,6 @@ function verifyApiKey({ configurations, schema, deleteAllExpiredApiKeys }) { }, key: null }); diff --git a/patches/@better-auth%2Fcore@1.7.5.patch b/patches/@better-auth%2Fcore@1.7.5.patch deleted file mode 100644 index fe861273c5..0000000000 --- a/patches/@better-auth%2Fcore@1.7.5.patch +++ /dev/null @@ -1,179 +0,0 @@ -diff --git a/dist/social-providers/apple.mjs b/dist/social-providers/apple.mjs -index 9f4c8e610bc1806b67f82a44800e259116588161..b3dea9e8bc39b334995b36caa47b280f46fd90f8 100644 ---- a/dist/social-providers/apple.mjs -+++ b/dist/social-providers/apple.mjs -@@ -4,8 +4,8 @@ import { getPrimaryClientId } from "../oauth2/utils.mjs"; - import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs"; - import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs"; - import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs"; -+import { fetchRefusingRedirects } from "../oauth2/reject-redirects.mjs"; - import { decodeJwt, importJWK } from "jose"; --import { betterFetch } from "@better-fetch/fetch"; - //#region src/social-providers/apple.ts - const apple = (options) => { - const tokenEndpoint = "https://appleid.apple.com/auth/token"; -@@ -85,7 +85,7 @@ const apple = (options) => { - }; - }; - const getApplePublicKey = async (kid) => { -- const { data } = await betterFetch(`https://appleid.apple.com/auth/keys`); -+ const { data } = await fetchRefusingRedirects(`https://appleid.apple.com/auth/keys`); - if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" }); - const jwk = data.keys.find((key) => key.kid === kid); - if (!jwk) throw new Error(`JWK with kid ${kid} not found`); -diff --git a/dist/social-providers/cognito.mjs b/dist/social-providers/cognito.mjs -index 5b5c070b048408d8eec7f543e322f0f41c8d88df..9404e2635278b2be2755b7ea0ac346a5417864d2 100644 ---- a/dist/social-providers/cognito.mjs -+++ b/dist/social-providers/cognito.mjs -@@ -4,6 +4,7 @@ import { getPrimaryClientId } from "../oauth2/utils.mjs"; - import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs"; - import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs"; - import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs"; -+import { fetchRefusingRedirects } from "../oauth2/reject-redirects.mjs"; - import { decodeJwt, importJWK } from "jose"; - import { betterFetch } from "@better-fetch/fetch"; - //#region src/social-providers/cognito.ts -@@ -136,7 +137,7 @@ const cognito = (options) => { - const getCognitoPublicKey = async (kid, region, userPoolId) => { - const COGNITO_JWKS_URI = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/jwks.json`; - try { -- const { data } = await betterFetch(COGNITO_JWKS_URI); -+ const { data } = await fetchRefusingRedirects(COGNITO_JWKS_URI); - if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" }); - const jwk = data.keys.find((key) => key.kid === kid); - if (!jwk) throw new Error(`JWK with kid ${kid} not found`); -diff --git a/dist/social-providers/facebook.mjs b/dist/social-providers/facebook.mjs -index cba8ca1b45f3811189bf130b1f12de4346381947..a71363cbe28cc5d1465609d66cd49a3cef7cfca9 100644 ---- a/dist/social-providers/facebook.mjs -+++ b/dist/social-providers/facebook.mjs -@@ -4,6 +4,7 @@ import { getPrimaryClientId } from "../oauth2/utils.mjs"; - import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs"; - import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs"; - import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs"; -+import { fetchRefusingRedirects } from "../oauth2/reject-redirects.mjs"; - import { createRemoteJWKSet, decodeJwt } from "jose"; - import { betterFetch } from "@better-fetch/fetch"; - //#region src/social-providers/facebook.ts -@@ -26,7 +27,7 @@ async function verifyFacebookAccessToken(accessToken, options) { - const primaryClientId = getPrimaryClientId(options.clientId); - if (!primaryClientId || !options.clientSecret) return null; - const clientIds = Array.isArray(options.clientId) ? options.clientId : [options.clientId]; -- const { data, error } = await betterFetch("https://graph.facebook.com/debug_token", { query: { -+ const { data, error } = await fetchRefusingRedirects("https://graph.facebook.com/debug_token", { query: { - input_token: accessToken, - access_token: `${primaryClientId}|${options.clientSecret}` - } }); -diff --git a/dist/social-providers/github.mjs b/dist/social-providers/github.mjs -index b65f4eb43c9eb3dc3737eff381a222d747cedf7e..c20b904036530f7863d66a872962c125ce2d1beb 100644 ---- a/dist/social-providers/github.mjs -+++ b/dist/social-providers/github.mjs -@@ -3,6 +3,7 @@ import { getOAuth2Tokens } from "../oauth2/utils.mjs"; - import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs"; - import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs"; - import { authorizationCodeRequest } from "../oauth2/validate-authorization-code.mjs"; -+import { fetchRefusingRedirects } from "../oauth2/reject-redirects.mjs"; - import { betterFetch } from "@better-fetch/fetch"; - //#region src/social-providers/github.ts - const github = (options) => { -@@ -35,7 +36,7 @@ const github = (options) => { - redirectURI, - options - }); -- const { data, error } = await betterFetch(tokenEndpoint, { -+ const { data, error } = await fetchRefusingRedirects(tokenEndpoint, { - method: "POST", - body, - headers: requestHeaders -diff --git a/dist/social-providers/google.mjs b/dist/social-providers/google.mjs -index 0c40ed6650cbd93bdfd5e2d8292e3a26b61e636b..687b1dcdefcac4eef899099c1b592ca532be6bb3 100644 ---- a/dist/social-providers/google.mjs -+++ b/dist/social-providers/google.mjs -@@ -4,8 +4,8 @@ import { getPrimaryClientId } from "../oauth2/utils.mjs"; - import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs"; - import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs"; - import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs"; -+import { fetchRefusingRedirects } from "../oauth2/reject-redirects.mjs"; - import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; --import { betterFetch } from "@better-fetch/fetch"; - //#region src/social-providers/google.ts - const GOOGLE_ID_TOKEN_MAX_AGE = "1h"; - const GOOGLE_ID_TOKEN_ALGORITHM = "RS256"; -@@ -142,7 +142,7 @@ const getGooglePublicKey = async (kid) => { - return publicKey; - }; - const getGooglePublicKeys = async (kid) => { -- const { data } = await betterFetch("https://www.googleapis.com/oauth2/v3/certs"); -+ const { data } = await fetchRefusingRedirects("https://www.googleapis.com/oauth2/v3/certs"); - if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" }); - const jwks = kid ? data.keys.filter((key) => key.kid === kid) : data.keys; - if (!jwks.length) throw new Error(`JWK with kid ${kid} not found`); -diff --git a/dist/social-providers/line.mjs b/dist/social-providers/line.mjs -index 031dcecaab32a7fa3897c733811f08e0dfa4db91..fee623c638ee4f18bcc367262f16e9d97d95b89c 100644 ---- a/dist/social-providers/line.mjs -+++ b/dist/social-providers/line.mjs -@@ -1,6 +1,7 @@ - import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs"; - import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs"; - import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs"; -+import { fetchRefusingRedirects } from "../oauth2/reject-redirects.mjs"; - import { decodeJwt } from "jose"; - import { betterFetch } from "@better-fetch/fetch"; - //#region src/social-providers/line.ts -@@ -66,7 +67,7 @@ const line = (options) => { - body.set("id_token", token); - body.set("client_id", options.clientId); - if (nonce) body.set("nonce", nonce); -- const { data, error } = await betterFetch(verifyIdTokenEndpoint, { -+ const { data, error } = await fetchRefusingRedirects(verifyIdTokenEndpoint, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body -diff --git a/dist/social-providers/microsoft-entra-id.mjs b/dist/social-providers/microsoft-entra-id.mjs -index 67a197c8967d9922b5c34a70c2c0ea59b067b7aa..a4557936b6b8dfb9e4e73f1f3f40a8b269e9b2c9 100644 ---- a/dist/social-providers/microsoft-entra-id.mjs -+++ b/dist/social-providers/microsoft-entra-id.mjs -@@ -4,6 +4,7 @@ import { getPrimaryClientId } from "../oauth2/utils.mjs"; - import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs"; - import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs"; - import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs"; -+import { fetchRefusingRedirects } from "../oauth2/reject-redirects.mjs"; - import { base64 } from "@better-auth/utils/base64"; - import { decodeJwt, importJWK } from "jose"; - import { betterFetch } from "@better-fetch/fetch"; -@@ -152,7 +153,7 @@ const microsoft = (options) => { - }; - }; - const getMicrosoftPublicKey = async (kid, tenant, authority) => { -- const { data } = await betterFetch(`${authority}/${tenant}/discovery/v2.0/keys`); -+ const { data } = await fetchRefusingRedirects(`${authority}/${tenant}/discovery/v2.0/keys`); - if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" }); - const jwk = data.keys.find((key) => key.kid === kid); - if (!jwk) throw new Error(`JWK with kid ${kid} not found`); -diff --git a/dist/social-providers/wechat.mjs b/dist/social-providers/wechat.mjs -index 78b99de5cb5d2d5dc8c183c7b38a00fce2af0d14..f13bc2cc095dc5648319c16bee7b1de01fbc9b60 100644 ---- a/dist/social-providers/wechat.mjs -+++ b/dist/social-providers/wechat.mjs -@@ -1,4 +1,5 @@ - import { RESERVED_AUTHORIZATION_PARAMS_SET } from "../oauth2/create-authorization-url.mjs"; -+import { fetchRefusingRedirects } from "../oauth2/reject-redirects.mjs"; - import { createPlaceholderEmail } from "../utils/email.mjs"; - import { betterFetch } from "@better-fetch/fetch"; - //#region src/social-providers/wechat.ts -@@ -27,7 +28,7 @@ const wechat = (options) => { - return url; - }, - validateAuthorizationCode: async ({ code }) => { -- const { data: tokenData, error } = await betterFetch("https://api.weixin.qq.com/sns/oauth2/access_token?" + new URLSearchParams({ -+ const { data: tokenData, error } = await fetchRefusingRedirects("https://api.weixin.qq.com/sns/oauth2/access_token?" + new URLSearchParams({ - appid: options.clientId, - secret: options.clientSecret, - code, -@@ -45,7 +46,7 @@ const wechat = (options) => { - }; - }, - refreshAccessToken: options.refreshAccessToken ? options.refreshAccessToken : async (refreshToken) => { -- const { data: tokenData, error } = await betterFetch("https://api.weixin.qq.com/sns/oauth2/refresh_token?" + new URLSearchParams({ -+ const { data: tokenData, error } = await fetchRefusingRedirects("https://api.weixin.qq.com/sns/oauth2/refresh_token?" + new URLSearchParams({ - appid: options.clientId, - grant_type: "refresh_token", - refresh_token: refreshToken diff --git a/patches/@better-auth%2Foauth-provider@1.7.5.patch b/patches/@better-auth%2Foauth-provider@1.7.16.patch similarity index 60% rename from patches/@better-auth%2Foauth-provider@1.7.5.patch rename to patches/@better-auth%2Foauth-provider@1.7.16.patch index 5506278f4d..67b834de0a 100644 --- a/patches/@better-auth%2Foauth-provider@1.7.5.patch +++ b/patches/@better-auth%2Foauth-provider@1.7.16.patch @@ -1,84 +1,8 @@ -diff --git a/dist/authorize-riRRCSbC.mjs b/dist/authorize-riRRCSbC.mjs -index 3242d48eb31e844240b0cd9f5931580f3c5367c2..4b5d68df6fd1a62466874e728e27bbf650c6e42a 100644 ---- a/dist/authorize-riRRCSbC.mjs -+++ b/dist/authorize-riRRCSbC.mjs -@@ -689,7 +689,7 @@ function authServerMetadata(ctx, opts, overrides) { - const backchannelSupported = !overrides?.jwt_disabled; - return { - scopes_supported: overrides?.scopes_supported, -- issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL), -+ issuer: overrides?.issuer ?? validateIssuerUrl(opts?.jwt?.issuer ?? baseURL), - authorization_endpoint: `${baseURL}/oauth2/authorize`, - token_endpoint: `${baseURL}/oauth2/token`, - jwks_uri: overrides?.jwt_disabled ? void 0 : opts?.jwks?.remoteUrl ?? `${baseURL}${opts?.jwks?.jwksPath ?? "/jwks"}`, -@@ -742,6 +742,7 @@ function buildAuthServerMetadata(ctx, opts) { - jwtPluginOptions, - clientDiscoveries, - authMetadata: authServerMetadata(ctx, jwtPluginOptions, { -+ issuer: getIssuer(ctx, opts), - scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes, - dynamic_client_registration_supported: opts.allowDynamicClientRegistration, - public_client_supported: publicClientSupported, -@@ -3437,7 +3438,7 @@ async function revokeJwtAccessToken(ctx, opts, token) { - return (await jwtPlugin?.endpoints.getJwks(ctx))?.response; - }, - jwksCacheKey: jwtPlugin -- })), { issuer: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL }); -+ })), { issuer: opts.issuer ?? jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL }); - const userInfoAudience = `${ctx.context.baseURL}/oauth2/userinfo`; - if (!verified.payload.azp || !await isAudienceClaimAllowed(ctx, opts, verified.payload.aud, [userInfoAudience])) return null; - } catch (error) { -@@ -4226,7 +4227,7 @@ const oauthProvider = (options) => { - const handleIssuerMetadataRequest = async (request, ctx) => { - const requestPathname = new URL(request.url).pathname; - const requestPath = ctx.options.advanced?.skipTrailingSlashes ? requestPathname.replace(/\/+$/, "") || "/" : requestPathname; -- const issuer = opts.disableJwtPlugin ? ctx.baseURL : getJwtPlugin(ctx)?.options?.jwt?.issuer ?? ctx.baseURL; -+ const issuer = opts.issuer ?? (opts.disableJwtPlugin ? ctx.baseURL : getJwtPlugin(ctx)?.options?.jwt?.issuer ?? ctx.baseURL); - let issuerPath = "/"; - try { - issuerPath = new URL(issuer).pathname.replace(/\/$/, "") || ""; -@@ -5363,7 +5364,8 @@ function validateIssuerUrl(issuer) { - */ - function getIssuer(ctx, opts) { - let issuer; -- if (opts.disableJwtPlugin) issuer = ctx.context.baseURL; -+ if (opts.issuer) issuer = opts.issuer; -+ else if (opts.disableJwtPlugin) issuer = ctx.context.baseURL; - else try { - issuer = getJwtPlugin(ctx.context).options?.jwt?.issuer ?? ctx.context.baseURL; - } catch { -diff --git a/dist/index.d.mts b/dist/index.d.mts -index 0f25dedd409ee3bf612b5a0641ee3745a4389625..dbcf7e73593a49e43cf8364de928c4d20268de74 100644 ---- a/dist/index.d.mts -+++ b/dist/index.d.mts -@@ -194,3 +194,6 @@ declare function consumeClientAssertion(ctx: GenericEndpointContext, opts: OAuth - }): Promise; - //#endregion --export { ActiveAccessTokenPayload, AuthMethod, AuthServerMetadata, AuthorizePrompt, BearerMethodsSupported, ClientDiscovery, ClientMetadataResourceFetch, ClientRegistrationRequest, Confirmation, DEFAULT_OAUTH_SCOPES, DEVICE_CODE_GRANT_TYPE, GrantType, InitialAccessTokenAuthorization, OAuthAuthenticatedClient, OAuthAuthorizationQuery, OAuthClaimExtensionInput, type OAuthClient, OAuthClientAdministrativeResponse, OAuthClientAuthenticationInput, OAuthClientAuthenticationRequest, OAuthClientAuthenticationResult, OAuthClientAuthenticationStrategy, type OAuthClientMetadata, OAuthClientRegistrationResponse, OAuthClientResource, OAuthConsent, type OAuthDeviceAuthorizationOptions, type OAuthEndpointErrorResult, type OAuthEndpointRedirectContext, type OAuthErrorCode, OAuthExtensionGrantHandler, OAuthExtensionGrantHandlerInput, type OAuthFieldErrorCode, type OAuthFieldErrorCodeMap, OAuthMetadataExtensionInput, OAuthOpaqueAccessToken, OAuthOptions, OAuthProviderApi, OAuthProviderExtension, type OAuthRedirectOnError, OAuthRefreshToken, OAuthResource, OAuthResourceInput, OAuthTokenIssueParams, OAuthTokenResponse, OAuthUserInfoExtensionInput, OIDCMetadata, Prompt, type ResourceServerMetadata, ResourceUriSchema, SchemaClient, Scope, StoreTokenType, StoredAuthorizationQuery, TokenEndpointAuthMethod, TokenType, VerificationValue, authServerMetadata, consumeClientAssertion, createResourceServerChallenge, extendOAuthProvider, getIssuer, getOAuthProviderApi, getOAuthProviderState, metadataResponse, oauthAuthorizationServerMetadata, oauthClientMetadataSchema, oauthDeviceAuthorization, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oidcServerMetadata }; -\ No newline at end of file -+export { ActiveAccessTokenPayload, AuthMethod, AuthServerMetadata, AuthorizePrompt, BearerMethodsSupported, ClientDiscovery, ClientMetadataResourceFetch, ClientRegistrationRequest, Confirmation, DEFAULT_OAUTH_SCOPES, DEVICE_CODE_GRANT_TYPE, GrantType, InitialAccessTokenAuthorization, OAuthAuthenticatedClient, OAuthAuthorizationQuery, OAuthClaimExtensionInput, type OAuthClient, OAuthClientAdministrativeResponse, OAuthClientAuthenticationInput, OAuthClientAuthenticationRequest, OAuthClientAuthenticationResult, OAuthClientAuthenticationStrategy, type OAuthClientMetadata, OAuthClientRegistrationResponse, OAuthClientResource, OAuthConsent, type OAuthDeviceAuthorizationOptions, type OAuthEndpointErrorResult, type OAuthEndpointRedirectContext, type OAuthErrorCode, OAuthExtensionGrantHandler, OAuthExtensionGrantHandlerInput, type OAuthFieldErrorCode, type OAuthFieldErrorCodeMap, OAuthMetadataExtensionInput, OAuthOpaqueAccessToken, OAuthOptions, OAuthProviderApi, OAuthProviderExtension, type OAuthRedirectOnError, OAuthRefreshToken, OAuthResource, OAuthResourceInput, OAuthTokenIssueParams, OAuthTokenResponse, OAuthUserInfoExtensionInput, OIDCMetadata, Prompt, type ResourceServerMetadata, ResourceUriSchema, SchemaClient, Scope, StoreTokenType, StoredAuthorizationQuery, TokenEndpointAuthMethod, TokenType, VerificationValue, authServerMetadata, consumeClientAssertion, createResourceServerChallenge, extendOAuthProvider, getIssuer, getOAuthProviderApi, getOAuthProviderState, metadataResponse, oauthAuthorizationServerMetadata, oauthClientMetadataSchema, oauthDeviceAuthorization, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oidcServerMetadata }; -+/** Provision configured resources after migrations; preserve existing policies and reject missing tables. */ -+export type OAuthResourceSeedContext = { adapter: Pick }; -+export declare function seedOAuthResources(ctx: OAuthResourceSeedContext, options: OAuthOptions): Promise; -\ No newline at end of file -diff --git a/dist/index.mjs b/dist/index.mjs -index 6e5728dacd87946d9553a497b39688b5d5d9304a..cc89e52fec360a8c1049aafa9a5e88d999b4ce24 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -229,3 +229,8 @@ function oauthDeviceAuthorization(options = {}) { - } - //#endregion - export { DEFAULT_OAUTH_SCOPES, DEVICE_CODE_GRANT_TYPE, ResourceUriSchema, authServerMetadata, consumeClientAssertion, createResourceServerChallenge, extendOAuthProvider, getIssuer, getOAuthProviderApi, getOAuthProviderState, metadataResponse, oauthAuthorizationServerMetadata, oauthClientMetadataSchema, oauthDeviceAuthorization, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oidcServerMetadata }; -+ -+// Explicit provisioning is insert-only and must finish after migrations. -+import { w as seedResourcesForProvisioning } from "./introspect-njKASm3q.mjs"; -+export const seedOAuthResources = (ctx, options) => -+ seedResourcesForProvisioning(ctx, { ...options, resourceSeedMode: "insertOnly" }, true); -diff --git a/dist/introspect-njKASm3q.mjs b/dist/introspect-njKASm3q.mjs -index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a5593892629246aa0 100644 ---- a/dist/introspect-njKASm3q.mjs -+++ b/dist/introspect-njKASm3q.mjs -@@ -676,6 +676,8 @@ async function getResource(ctx, opts, identifier) { +diff --git a/dist/authorize-BnSO_Slc.mjs b/dist/authorize-BnSO_Slc.mjs +index ad18021..1c194f6 100644 +--- a/dist/authorize-BnSO_Slc.mjs ++++ b/dist/authorize-BnSO_Slc.mjs +@@ -1016,6 +1016,8 @@ async function getResource(ctx, opts, identifier) { value: identifier }] }); @@ -87,7 +11,7 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 if (dbResource && opts.cachedResources?.has(identifier)) resourceCache.set(identifier, Object.assign({}, dbResource)); return dbResource; } -@@ -802,7 +804,8 @@ function getSeedState(ctx) { +@@ -1167,7 +1169,8 @@ function getSeedState(ctx, opts) { * * @internal */ @@ -97,7 +21,7 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 const inputs = collectResourceInputs(opts); if (inputs.length === 0) return; const mode = opts.resourceSeedMode ?? "insertOnly"; -@@ -810,6 +813,7 @@ async function seedResources(ctx, opts) { +@@ -1175,6 +1178,7 @@ async function seedResources(ctx, opts) { for (const rawInput of inputs) { const check = await checkIdentifier(opts, rawInput.identifier); if (!check.ok) { @@ -105,29 +29,7 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 logger.warn(`oauth-provider: skipping resource seed for ${rawInput.identifier} — ${check.reason}`); continue; } -@@ -830,7 +834,7 @@ async function seedResources(ctx, opts) { - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); -- if (MISSING_TABLE_PATTERN.test(message)) { -+ if (!strict && MISSING_TABLE_PATTERN.test(message)) { - logger.debug("oauth-provider: oauthResource table not yet created; deferring resource seed to first access."); - return; - } -@@ -846,10 +850,11 @@ async function seedResources(ctx, opts) { - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (/unique|duplicate|UNIQUE/i.test(message)) { -+ if (strict && !await ctx.adapter.findOne({ model: modelName, where: [{ field: "identifier", value: input.identifier }] })) throw err; - logger.debug(`oauth-provider: resource ${input.identifier} already inserted by a concurrent process — skipping.`); - continue; - } -- if (MISSING_TABLE_PATTERN.test(message)) { -+ if (!strict && MISSING_TABLE_PATTERN.test(message)) { - logger.debug("oauth-provider: oauthResource table not yet created; deferring resource seed to first access."); - return; - } -@@ -1364,7 +1369,7 @@ async function createJwtAccessToken(ctx, opts, user, client, audienceClaim, scop +@@ -1539,7 +1543,7 @@ async function createJwtAccessToken(ctx, opts, user, client, audienceClaim, scop azp: client.clientId, scope: scopes.join(" "), sid: overrides?.sid, @@ -136,7 +38,7 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 iat, exp, jti: generateRandomString(32), -@@ -1410,7 +1415,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, +@@ -1585,7 +1589,7 @@ async function createIdToken(ctx, opts, user, client, scopes, nonce, sessionId, acr: "0", ...customClaims, at_hash: atHash, @@ -145,7 +47,7 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 sub: resolvedSub, aud: client.clientId, nonce, -@@ -1588,10 +1593,13 @@ async function createRefreshToken(ctx, opts, user, referenceId, authorizationCod +@@ -1765,10 +1769,13 @@ async function createRefreshToken(ctx, opts, user, referenceId, authorizationCod }], increment: {}, set: rotationUpdate @@ -163,7 +65,7 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 return { id: (await ctx.context.adapter.create({ model: "oauthRefreshToken", -@@ -2097,6 +2105,17 @@ async function handleClientCredentialsGrant(ctx, opts) { +@@ -2280,6 +2287,17 @@ async function handleClientCredentialsGrant(ctx, opts) { * Refresh tokens will only allow the same or lesser scopes as the initial authorize request. * To add scopes, you must restart the authorize process again. */ @@ -179,9 +81,9 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 + } +} async function handleRefreshTokenGrant(ctx, opts) { - const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`)); - const { refresh_token, scope, resource } = ctx.body; -@@ -2117,18 +2136,27 @@ async function handleRefreshTokenGrant(ctx, opts) { + const credentials = await extractClientCredentials(ctx, opts, `${ctx.context.baseURL}/oauth2/token`); + const { clientId: client_id, clientSecret: client_secret, preVerified, authMethod } = destructureCredentials(credentials); +@@ -2305,18 +2323,27 @@ async function handleRefreshTokenGrant(ctx, opts) { value: await getStoredToken(opts.storeTokens, decodedRefresh.token, "refresh_token") }] }); @@ -221,7 +123,7 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 if (resources && refreshToken.resources && !resources.every((v) => refreshToken.resources?.includes(v))) throw new APIError("BAD_REQUEST", { error_description: "requested resource invalid", error: "invalid_target" -@@ -2152,12 +2180,21 @@ async function handleRefreshTokenGrant(ctx, opts) { +@@ -2340,12 +2367,21 @@ async function handleRefreshTokenGrant(ctx, opts) { resources: resources ?? refreshToken.resources })); if (replay) return replayRefreshTokenRotationResponse(ctx, replay); @@ -243,16 +145,16 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 throw new APIError("BAD_REQUEST", { error_description: "invalid refresh token", error: "invalid_grant" -@@ -2238,7 +2275,7 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) { - const jwtPlugin = opts.disableJwtPlugin ? void 0 : getJwtPlugin(ctx.context); - const jwtPluginOptions = jwtPlugin?.options; +@@ -2430,7 +2466,7 @@ async function validateJwtAccessToken(ctx, opts, token, clientId) { + const jwtPlugin = getJwtPlugin(ctx.context); + const jwtPluginOptions = jwtPlugin.options; const userInfoAud = `${ctx.context.baseURL ?? ""}/oauth2/userinfo`; - const expectedIssuer = jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL; + const expectedIssuer = opts.issuer ?? jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL; let jwtPayload; try { - jwtPayload = (await jwtVerify(token, createLocalJWKSet(await getJwks(token, { -@@ -2360,7 +2397,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) { + const jwksFetch = jwtPluginOptions?.jwks?.remoteUrl ? jwtPluginOptions.jwks.remoteUrl : async () => { +@@ -2552,7 +2588,7 @@ async function validateOpaqueAccessToken(ctx, opts, token, clientId) { payload: { ...accessTokenClaims, active: true, @@ -261,7 +163,7 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 aud: toAudienceClaim(audienceClaim), client_id: accessToken.clientId, azp: accessToken.clientId, -@@ -2411,7 +2448,7 @@ async function validateRefreshToken(ctx, opts, token, clientId) { +@@ -2603,7 +2639,7 @@ async function validateRefreshToken(ctx, opts, token, clientId) { return { active: true, client_id: clientId, @@ -270,55 +172,89 @@ index c8d635e57ca4cd3fe8f7cbfd5c769d61b7e96874..21d78930a1564b2d380c4b3a55938926 sub: user?.id, sid: sessionId, exp: Math.floor(new Date(refreshToken.expiresAt).getTime() / 1e3), -diff --git a/dist/oauth-1Ud-hvZY.d.mts b/dist/oauth-1Ud-hvZY.d.mts -index 130fd5d8f3ddd36d1b776338367af5b8cb7f0e79..a49e282e0c52c175591a28afe2217103a861dc6c 100644 ---- a/dist/oauth-1Ud-hvZY.d.mts -+++ b/dist/oauth-1Ud-hvZY.d.mts -@@ -1131,7 +1131,10 @@ interface OAuthOptions void; - /** - * The amount of time in seconds that the authorization code is valid for. - * -diff --git a/dist/oauth-CJ85_W0W.d.mts b/dist/oauth-CJ85_W0W.d.mts -index 4e3c1a512ab3a3658e4a22516d2dfcea0b001ac5..ed0a93b51c638be33858e598cda0372dbc4a45ee 100644 ---- a/dist/oauth-CJ85_W0W.d.mts -+++ b/dist/oauth-CJ85_W0W.d.mts -@@ -110,7 +110,6 @@ declare function buildOAuthDeviceGrant(): { +@@ -3254,7 +3290,7 @@ function authServerMetadata(ctx, opts, overrides) { + const backchannelSupported = !overrides?.jwt_disabled; + return { + scopes_supported: overrides?.scopes_supported, +- issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL), ++ issuer: overrides?.issuer ?? validateIssuerUrl(opts?.jwt?.issuer ?? baseURL), + authorization_endpoint: `${baseURL}/oauth2/authorize`, + token_endpoint: `${baseURL}/oauth2/token`, + jwks_uri: overrides?.jwt_disabled ? void 0 : opts?.jwks?.remoteUrl ?? `${baseURL}${opts?.jwks?.jwksPath ?? "/jwks"}`, +@@ -3307,6 +3343,7 @@ function buildAuthServerMetadata(ctx, opts) { + jwtPluginOptions, + clientDiscoveries, + authMetadata: authServerMetadata(ctx, jwtPluginOptions, { ++ issuer: getIssuer(ctx, opts), + scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes, + dynamic_client_registration_supported: opts.allowDynamicClientRegistration, + public_client_supported: publicClientSupported, +@@ -6158,7 +6195,7 @@ async function revokeJwtAccessToken(ctx, opts, token) { + jwksFetch, + jwksCacheKey: jwtPlugin + }); +- jwtPayload = (await jwtVerify(token, createLocalJWKSet(jwks), { issuer: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL })).payload; ++ jwtPayload = (await jwtVerify(token, createLocalJWKSet(jwks), { issuer: opts.issuer ?? jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL })).payload; + } catch (error) { + if (error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid) return null; + if (error instanceof TypeError || isInvalidJwtError(error)) throw new APIError$1("BAD_REQUEST", { +@@ -6946,7 +6983,7 @@ const oauthProvider = (options) => { + const handleIssuerMetadataRequest = async (request, ctx) => { + const requestPathname = new URL(request.url).pathname; + const requestPath = ctx.options.advanced?.skipTrailingSlashes ? requestPathname.replace(/\/+$/, "") || "/" : requestPathname; +- const issuer = opts.disableJwtPlugin ? ctx.baseURL : getJwtPlugin(ctx)?.options?.jwt?.issuer ?? ctx.baseURL; ++ const issuer = opts.issuer ?? (opts.disableJwtPlugin ? ctx.baseURL : getJwtPlugin(ctx)?.options?.jwt?.issuer ?? ctx.baseURL); + let issuerPath = "/"; + try { + issuerPath = new URL(issuer).pathname.replace(/\/$/, "") || ""; +@@ -8108,7 +8145,8 @@ function validateIssuerUrl(issuer) { + */ + function getIssuer(ctx, opts) { + let issuer; +- if (opts.disableJwtPlugin) issuer = ctx.context.baseURL; ++ if (opts.issuer) issuer = opts.issuer; ++ else if (opts.disableJwtPlugin) issuer = ctx.context.baseURL; + else try { + issuer = getJwtPlugin(ctx.context).options?.jwt?.issuer ?? ctx.context.baseURL; + } catch { +@@ -8517,3 +8555,4 @@ async function signParams(ctx, opts, flags) { + } + //#endregion + export { extractRepeatedResourceFromForm as _, registerClientMetadataDocument as a, oauthClientMetadataSchema as b, isForbiddenCimdClientMetadataField as c, metadataResponse as d, oauthAuthorizationServerMetadata as f, getOAuthProviderApi as g, oidcServerMetadata as h, oauthProvider as i, validatePublicClientJwks as l, oauthProviderOpenIdConfigMetadata as m, DEFAULT_OAUTH_SCOPES as n, client_assertion_exports as o, oauthProviderAuthServerMetadata as p, getOAuthProviderState as r, consumeClientAssertion as s, getIssuer as t, authServerMetadata as u, resolveResourcePolicy as v, ResourceUriSchema as y }; ++export { seedResources as seedResourcesForProvisioning }; +diff --git a/dist/index.d.mts b/dist/index.d.mts +index 64268b1..8d3b678 100644 +--- a/dist/index.d.mts ++++ b/dist/index.d.mts +@@ -202,4 +202,7 @@ export declare function consumeClientAssertion(ctx: GenericEndpointContext, opts + expectedAudience: string; + }): Promise; + //#endregion +-export { type ActiveAccessTokenPayload, type AuthMethod, type AuthServerMetadata, type AuthorizePrompt, type BearerMethodsSupported, type ClientDiscovery, type ClientMetadataResourceFetch, type ClientRegistrationRequest, type Confirmation, DEFAULT_OAUTH_SCOPES, DEVICE_CODE_GRANT_TYPE, type GrantType, type InitialAccessTokenAuthorization, type OAuthAuthenticatedClient, type OAuthAuthorizationQuery, type OAuthClaimExtensionInput, type OAuthClient, type OAuthClientAdministrativeResponse, type OAuthClientAuthenticationInput, type OAuthClientAuthenticationRequest, type OAuthClientAuthenticationResult, type OAuthClientAuthenticationStrategy, type OAuthClientMetadata, type OAuthClientRegistrationResponse, type OAuthClientResource, type OAuthConsent, type OAuthDeviceAuthorizationOptions, type OAuthEndpointErrorResult, type OAuthEndpointRedirectContext, type OAuthErrorCode, type OAuthExtensionGrantHandler, type OAuthExtensionGrantHandlerInput, type OAuthFieldErrorCode, type OAuthFieldErrorCodeMap, type OAuthMetadataExtensionInput, type OAuthOpaqueAccessToken, type OAuthOptions, type OAuthProviderApi, type OAuthProviderExtension, type OAuthRedirectOnError, type OAuthRefreshToken, type OAuthResource, type OAuthResourceInput, type OAuthTokenIssueParams, type OAuthTokenResponse, type OAuthUserInfoExtensionInput, type OIDCMetadata, type Prompt, type ResourceServerMetadata, ResourceUriSchema, type SchemaClient, type Scope, type StoreTokenType, type StoredAuthorizationQuery, type TokenEndpointAuthMethod, type TokenType, type VerificationValue, getIssuer, getOAuthProviderState, oauthClientMetadataSchema, oauthDeviceAuthorization, oauthProvider }; +\ No newline at end of file ++export { type ActiveAccessTokenPayload, type AuthMethod, type AuthServerMetadata, type AuthorizePrompt, type BearerMethodsSupported, type ClientDiscovery, type ClientMetadataResourceFetch, type ClientRegistrationRequest, type Confirmation, DEFAULT_OAUTH_SCOPES, DEVICE_CODE_GRANT_TYPE, type GrantType, type InitialAccessTokenAuthorization, type OAuthAuthenticatedClient, type OAuthAuthorizationQuery, type OAuthClaimExtensionInput, type OAuthClient, type OAuthClientAdministrativeResponse, type OAuthClientAuthenticationInput, type OAuthClientAuthenticationRequest, type OAuthClientAuthenticationResult, type OAuthClientAuthenticationStrategy, type OAuthClientMetadata, type OAuthClientRegistrationResponse, type OAuthClientResource, type OAuthConsent, type OAuthDeviceAuthorizationOptions, type OAuthEndpointErrorResult, type OAuthEndpointRedirectContext, type OAuthErrorCode, type OAuthExtensionGrantHandler, type OAuthExtensionGrantHandlerInput, type OAuthFieldErrorCode, type OAuthFieldErrorCodeMap, type OAuthMetadataExtensionInput, type OAuthOpaqueAccessToken, type OAuthOptions, type OAuthProviderApi, type OAuthProviderExtension, type OAuthRedirectOnError, type OAuthRefreshToken, type OAuthResource, type OAuthResourceInput, type OAuthTokenIssueParams, type OAuthTokenResponse, type OAuthUserInfoExtensionInput, type OIDCMetadata, type Prompt, type ResourceServerMetadata, ResourceUriSchema, type SchemaClient, type Scope, type StoreTokenType, type StoredAuthorizationQuery, type TokenEndpointAuthMethod, type TokenType, type VerificationValue, getIssuer, getOAuthProviderState, oauthClientMetadataSchema, oauthDeviceAuthorization, oauthProvider }; ++/** Provision configured resources after migrations; preserve existing policies and reject missing tables. */ ++export type OAuthResourceSeedContext = { adapter: Pick }; ++export declare function seedOAuthResources(ctx: OAuthResourceSeedContext, options: OAuthOptions): Promise; +\ No newline at end of file +diff --git a/dist/index.mjs b/dist/index.mjs +index ef4cb4b..48649b0 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -229,3 +229,8 @@ function oauthDeviceAuthorization(options = {}) { + } + //#endregion + export { DEFAULT_OAUTH_SCOPES, DEVICE_CODE_GRANT_TYPE, ResourceUriSchema, authServerMetadata, consumeClientAssertion, createResourceServerChallenge, extendOAuthProvider, getIssuer, getOAuthProviderApi, getOAuthProviderState, metadataResponse, oauthAuthorizationServerMetadata, oauthClientMetadataSchema, oauthDeviceAuthorization, oauthProvider, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oidcServerMetadata, verifyOAuthQueryParams }; ++ ++// Explicit provisioning is insert-only and must finish after migrations. ++import { seedResourcesForProvisioning } from "./authorize-BnSO_Slc.mjs"; ++export const seedOAuthResources = (ctx, options) => ++ seedResourcesForProvisioning(ctx, { ...options, resourceSeedMode: "insertOnly" }, true); +diff --git a/dist/oauth-Cl_x7fAS.d.mts b/dist/oauth-Cl_x7fAS.d.mts +index 3c2ba5d..ad3f1da 100644 +--- a/dist/oauth-Cl_x7fAS.d.mts ++++ b/dist/oauth-Cl_x7fAS.d.mts +@@ -102,7 +102,6 @@ declare function buildOAuthDeviceGrant(): { resource: { oneOf: ({ type: string; @@ -326,7 +262,7 @@ index 4e3c1a512ab3a3658e4a22516d2dfcea0b001ac5..ed0a93b51c638be33858e598cda0372d } | { type: string; items: { -@@ -786,9 +785,6 @@ declare const oauthProvider: >(options: O) => { +@@ -775,9 +774,6 @@ declare const oauthProvider: >(options: O) => { required: false; schema: { type: "string"; @@ -336,7 +272,7 @@ index 4e3c1a512ab3a3658e4a22516d2dfcea0b001ac5..ed0a93b51c638be33858e598cda0372d }; description: string; } | { -@@ -797,9 +793,6 @@ declare const oauthProvider: >(options: O) => { +@@ -786,9 +782,6 @@ declare const oauthProvider: >(options: O) => { required: true; schema: { type: "string"; @@ -346,7 +282,7 @@ index 4e3c1a512ab3a3658e4a22516d2dfcea0b001ac5..ed0a93b51c638be33858e598cda0372d }; description: string; } | { -@@ -809,8 +802,6 @@ declare const oauthProvider: >(options: O) => { +@@ -798,8 +791,6 @@ declare const oauthProvider: >(options: O) => { schema: { type: "string"; format: string; @@ -355,7 +291,7 @@ index 4e3c1a512ab3a3658e4a22516d2dfcea0b001ac5..ed0a93b51c638be33858e598cda0372d }; description: string; } | { -@@ -820,8 +811,6 @@ declare const oauthProvider: >(options: O) => { +@@ -809,8 +800,6 @@ declare const oauthProvider: >(options: O) => { schema: { type: "integer"; minimum: number; @@ -364,7 +300,7 @@ index 4e3c1a512ab3a3658e4a22516d2dfcea0b001ac5..ed0a93b51c638be33858e598cda0372d }; description: string; } | { -@@ -833,8 +822,6 @@ declare const oauthProvider: >(options: O) => { +@@ -822,8 +811,6 @@ declare const oauthProvider: >(options: O) => { items: { type: "string"; }; @@ -373,7 +309,7 @@ index 4e3c1a512ab3a3658e4a22516d2dfcea0b001ac5..ed0a93b51c638be33858e598cda0372d }; description: string; })[]; -@@ -1073,7 +1060,6 @@ declare const oauthProvider: >(options: O) => { +@@ -1072,7 +1059,6 @@ declare const oauthProvider: >(options: O) => { oneOf: ({ type: string; description: string; @@ -381,3 +317,47 @@ index 4e3c1a512ab3a3658e4a22516d2dfcea0b001ac5..ed0a93b51c638be33858e598cda0372d } | { type: string; items: { +diff --git a/dist/oauth-m0aNFIHP.d.mts b/dist/oauth-m0aNFIHP.d.mts +index dacd1ef..7db2ded 100644 +--- a/dist/oauth-m0aNFIHP.d.mts ++++ b/dist/oauth-m0aNFIHP.d.mts +@@ -1183,7 +1183,10 @@ interface OAuthOptions void; + /** + * The amount of time in seconds that the authorization code is valid for. + * diff --git a/patches/@better-auth%2Fpasskey@1.7.5.patch b/patches/@better-auth%2Fpasskey@1.7.16.patch similarity index 58% rename from patches/@better-auth%2Fpasskey@1.7.5.patch rename to patches/@better-auth%2Fpasskey@1.7.16.patch index 45204bc9ad..366db9cdc1 100644 --- a/patches/@better-auth%2Fpasskey@1.7.5.patch +++ b/patches/@better-auth%2Fpasskey@1.7.16.patch @@ -1,22 +1,42 @@ diff --git a/dist/index.mjs b/dist/index.mjs -index e22a1ce93080807b06d818dd03074add11f39e16..6f4c27c57893ad1c1bfd6a8ceff9de36ff3d29e8 100644 +index 0ff2715..4c66119 100644 --- a/dist/index.mjs +++ b/dist/index.mjs -@@ -4,11 +4,12 @@ import { createAuthEndpoint } from "@better-auth/core/api"; +@@ -3,15 +3,15 @@ import { mergeSchema } from "better-auth/db"; + import { createAuthEndpoint } from "@better-auth/core/api"; import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context"; - import { APIError } from "@better-auth/core/error"; - import { base64 } from "@better-auth/utils/base64"; + import { APIError, BetterAuthError } from "@better-auth/core/error"; +-import { base64 } from "@better-auth/utils/base64"; ++import { base64, base64Url } from "@better-auth/utils/base64"; + import { createHash } from "@better-auth/utils/hash"; -import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server"; - import { freshSessionMiddleware, getSessionFromCtx, requireResourceOwnership, sessionMiddleware } from "better-auth/api"; + import { freshSessionMiddleware, getAuthoritativeSessionFromCtx, getSessionFromCtx, isStateful, requireResourceOwnership, sensitiveSessionMiddleware, sessionMiddleware } from "better-auth/api"; import { setSessionCookie } from "better-auth/cookies"; import { generateRandomString } from "better-auth/crypto"; import * as z from "zod"; + import { safeJSONParse } from "@better-auth/core/utils/json"; +-import { isoBase64URL } from "@simplewebauthn/server/helpers"; +// Attestation and certificate verification are large; load them only for passkey ceremonies. +const loadWebAuthn = () => import("@simplewebauthn/server"); //#region src/utils.ts function getRpID(options, baseURL) { return options.rpID || (baseURL ? new URL(baseURL).hostname : "localhost"); -@@ -163,7 +164,7 @@ const generatePasskeyRegistrationOptions = (opts, { maxAgeInSeconds }) => { +@@ -40,7 +40,13 @@ function getServerOrigins(options, ctx) { + } + function getReportedOrigin(clientDataJSON) { + if (typeof clientDataJSON !== "string") return null; +- const clientData = safeJSONParse(isoBase64URL.toUTF8String(clientDataJSON)); ++ let decoded; ++ try { ++ decoded = new TextDecoder().decode(base64Url.decode(clientDataJSON)); ++ } catch { ++ return null; ++ } ++ const clientData = safeJSONParse(decoded); + return typeof clientData?.origin === "string" ? clientData.origin : null; + } + /** +@@ -239,7 +245,7 @@ const generatePasskeyRegistrationOptions = (opts, { maxAgeInSeconds }) => { const registrationExtensions = await resolveExtensions(opts.registration?.extensions, ctx); const userID = new TextEncoder().encode(generateRandomString(32, "a-z", "0-9")); const baseURLString = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : void 0; @@ -25,30 +45,30 @@ index e22a1ce93080807b06d818dd03074add11f39e16..6f4c27c57893ad1c1bfd6a8ceff9de36 rpName: opts.rpName || ctx.context.appName, rpID: getRpID(opts, baseURLString), userID, -@@ -273,7 +274,7 @@ const generatePasskeyAuthenticationOptions = (opts, { maxAgeInSeconds }) => crea +@@ -350,7 +356,7 @@ const generatePasskeyAuthenticationOptions = (opts, { maxAgeInSeconds }) => crea }); const baseURLString = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : void 0; const authenticationExtensions = await resolveExtensions(opts.authentication?.extensions, ctx); - const options = await generateAuthenticationOptions({ + const options = await (await loadWebAuthn()).generateAuthenticationOptions({ rpID: getRpID(opts, baseURLString), - userVerification: "preferred", + userVerification: opts.authenticatorSelection?.userVerification ?? "preferred", extensions: authenticationExtensions, -@@ -347,7 +348,7 @@ const verifyPasskeyRegistration = (options) => { - const session = requireSession ? ctx.context.session : await getSessionFromCtx(ctx); +@@ -426,7 +432,7 @@ const verifyPasskeyRegistration = (options) => { if (session?.user?.id && userData.id !== session.user.id) throw APIError.from("UNAUTHORIZED", PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY); try { + const verifyBaseURL = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : void 0; - const verification = await verifyRegistrationResponse({ + const verification = await (await loadWebAuthn()).verifyRegistrationResponse({ response: resp, expectedChallenge, - expectedOrigin: origin, -@@ -469,7 +470,7 @@ const verifyPasskeyAuthentication = (options) => createAuthEndpoint("/passkey/ve - }); + expectedOrigin, +@@ -552,7 +558,7 @@ const verifyPasskeyAuthentication = (options) => createAuthEndpoint("/passkey/ve if (!passkey) throw APIError.from("UNAUTHORIZED", PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND); try { + const authBaseURL = typeof ctx.context.options.baseURL === "string" ? ctx.context.options.baseURL : void 0; - const verification = await verifyAuthenticationResponse({ + const verification = await (await loadWebAuthn()).verifyAuthenticationResponse({ response: resp, expectedChallenge, - expectedOrigin: origin, + expectedOrigin, diff --git a/patches/@better-auth%2Fsso@1.7.5.patch b/patches/@better-auth%2Fsso@1.7.16.patch similarity index 87% rename from patches/@better-auth%2Fsso@1.7.5.patch rename to patches/@better-auth%2Fsso@1.7.16.patch index b4a05fb5b2..b4dbeaa837 100644 --- a/patches/@better-auth%2Fsso@1.7.5.patch +++ b/patches/@better-auth%2Fsso@1.7.16.patch @@ -1,9 +1,9 @@ diff --git a/dist/index.mjs b/dist/index.mjs -index 5989b7213499593c256ffcb47cc3aee882916ac5..2d8b5a253a73b8851e136914a361a3646b27c457 100644 +index c5ede32..f6052f6 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -1,8 +1,6 @@ - import { t as PACKAGE_VERSION } from "./version-syldHUph.mjs"; + import { t as PACKAGE_VERSION } from "./version-CVbZQOHF.mjs"; -import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api"; -import { XMLParser, XMLValidator } from "fast-xml-parser"; +import { APIError, addOAuthServerContext, createAuthEndpoint as createNativeAuthEndpoint, createAuthMiddleware as createNativeAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api"; @@ -12,17 +12,16 @@ index 5989b7213499593c256ffcb47cc3aee882916ac5..2d8b5a253a73b8851e136914a361a364 import { generateRandomString } from "better-auth/crypto"; import * as z from "zod"; import { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, runWithTransaction } from "@better-auth/core/context"; -@@ -12,9 +10,6 @@ import { betterFetch } from "@better-fetch/fetch"; - import { createRemoteJWKSet, customFetch, jwtVerify } from "jose"; +@@ -13,8 +11,6 @@ import { betterFetch } from "@better-fetch/fetch"; + import { createRemoteJWKSet, customFetch } from "jose"; import { base64, base64Url } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; -import { DOMParser } from "@xmldom/xmldom"; --import * as samlifyNamespace from "samlify"; --import samlifyDefault from "samlify"; +-import samlifyDefault, * as samlifyNamespace from "samlify"; import { defineErrorCodes } from "@better-auth/core/utils/error-codes"; import { parseInputData, toZodSchema } from "better-auth/db"; import { isAPIError } from "@better-auth/core/utils/is-api-error"; -@@ -22,6 +17,45 @@ import { appendQueryParams } from "@better-auth/core/utils/url"; +@@ -22,6 +18,45 @@ import { appendQueryParams } from "@better-auth/core/utils/url"; import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth"; import { deleteSessionCookie, setAccountCookie, setSessionCookie } from "better-auth/cookies"; import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2"; @@ -68,7 +67,7 @@ index 5989b7213499593c256ffcb47cc3aee882916ac5..2d8b5a253a73b8851e136914a361a364 //#region src/constants.ts /** * SAML Constants -@@ -391,8 +425,7 @@ function computeDiscoveryUrl(issuer) { +@@ -392,8 +427,7 @@ function computeDiscoveryUrl(issuer) { * @throws DiscoveryError if URL is invalid */ function validateDiscoveryUrl(url, isTrustedOrigin) { @@ -78,7 +77,7 @@ index 5989b7213499593c256ffcb47cc3aee882916ac5..2d8b5a253a73b8851e136914a361a364 } /** * Validate that a user-supplied OIDC endpoint URL is safe to fetch. -@@ -682,10 +715,7 @@ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) { +@@ -685,10 +719,7 @@ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) { */ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) { const url = normalizeUrl(name, endpoint, issuer); @@ -90,7 +89,7 @@ index 5989b7213499593c256ffcb47cc3aee882916ac5..2d8b5a253a73b8851e136914a361a364 return url; } /** -@@ -964,12 +994,6 @@ async function isCurrentSSOProviderReference(provider, reference) { +@@ -967,12 +998,6 @@ async function isCurrentSSOProviderReference(provider, reference) { } //#endregion //#region src/saml/parser.ts @@ -103,7 +102,7 @@ index 5989b7213499593c256ffcb47cc3aee882916ac5..2d8b5a253a73b8851e136914a361a364 function findNode(obj, nodeName) { if (!obj || typeof obj !== "object") return null; const record = obj; -@@ -1176,7 +1200,6 @@ function validateConfigAlgorithms(config, options = {}) { +@@ -1176,7 +1201,6 @@ function validateConfigAlgorithms(config, options = {}) { }); } } @@ -111,7 +110,7 @@ index 5989b7213499593c256ffcb47cc3aee882916ac5..2d8b5a253a73b8851e136914a361a364 //#endregion //#region src/saml/assertions.ts const SAML_ASSERTION_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:assertion"; -@@ -4428,10 +4451,6 @@ const initiateSLO = (options) => { +@@ -4462,10 +4486,6 @@ const initiateSLO = (options) => { }; //#endregion //#region src/index.ts diff --git a/patches/better-auth-userinfo-claims.md b/patches/better-auth-userinfo-claims.md index fa8dbe1cd3..15f9d25cc2 100644 --- a/patches/better-auth-userinfo-claims.md +++ b/patches/better-auth-userinfo-claims.md @@ -1,6 +1,6 @@ # Better Auth Generic OAuth: UserInfo claims -`better-auth@1.7.5.patch` changes `fetchUserInfo` in +`better-auth@1.7.16.patch` changes `fetchUserInfo` in `dist/plugins/generic-oauth/index.mjs`. Self-host SSO uses this default fetch. An ID token that has `sub` and `email` used to be taken as the whole profile, diff --git a/patches/better-auth@1.7.16.patch b/patches/better-auth@1.7.16.patch new file mode 100644 index 0000000000..192432e855 --- /dev/null +++ b/patches/better-auth@1.7.16.patch @@ -0,0 +1,136 @@ +diff --git a/dist/api/rate-limiter/index.mjs b/dist/api/rate-limiter/index.mjs +index abe4c5e..6f07ec0 100644 +--- a/dist/api/rate-limiter/index.mjs ++++ b/dist/api/rate-limiter/index.mjs +@@ -88,7 +88,11 @@ function createDatabaseStorageWrapper(ctx) { + value: key + }] + }))[0]; +- if (data) data.lastRequest = Number(data.lastRequest); ++ if (data) { ++ const lastRequest = Number(data.lastRequest); ++ if (!Number.isSafeInteger(lastRequest) || lastRequest < 0) throw new BetterAuthError("Invalid persisted rate-limit timestamp"); ++ data.lastRequest = lastRequest; ++ } + return data; + }; + const consume = async (key, rule) => { +diff --git a/dist/plugins/generic-oauth/index.mjs b/dist/plugins/generic-oauth/index.mjs +index 8615d9a..bd17c28 100644 +--- a/dist/plugins/generic-oauth/index.mjs ++++ b/dist/plugins/generic-oauth/index.mjs +@@ -60,26 +60,39 @@ async function fetchDiscovery(url, headers, timeout) { + async function fetchUserInfo(tokens, userInfoUrl, idTokenClaims) { + if (tokens.idToken) { + const decoded = idTokenClaims ?? readIdTokenClaims(tokens.idToken); +- if (decoded?.sub && decoded?.email) return { +- id: decoded.sub, +- emailVerified: toBoolean(decoded.email_verified), +- image: decoded.picture, +- ...decoded +- }; ++ if (decoded?.sub && decoded?.email) { ++ const userInfo = decoded.email_verified === void 0 && userInfoUrl && tokens.accessToken ? await requestUserInfo(tokens, userInfoUrl) : null; ++ const { id: _, ...userInfoClaims } = userInfo && userInfo.sub === decoded.sub ? userInfo : {}; ++ const claims = { ++ ...userInfoClaims, ++ ...decoded ++ }; ++ return { ++ id: decoded.sub, ++ emailVerified: toBoolean(claims.email_verified), ++ image: claims.picture, ++ ...claims ++ }; ++ } + } + if (!userInfoUrl || !tokens.accessToken) return null; ++ const userInfo = await requestUserInfo(tokens, userInfoUrl); ++ if (!userInfo) return null; ++ return { ++ ...userInfo, ++ email: userInfo.email, ++ emailVerified: toBoolean(userInfo.email_verified), ++ image: userInfo.picture, ++ name: userInfo.name ++ }; ++} ++async function requestUserInfo(tokens, userInfoUrl) { + const userInfo = await betterFetch(userInfoUrl, { + method: "GET", + headers: { Authorization: `Bearer ${tokens.accessToken}` } + }); + if (userInfo.error || !userInfo.data) return null; +- return { +- ...userInfo.data, +- email: userInfo.data.email, +- emailVerified: toBoolean(userInfo.data.email_verified), +- image: userInfo.data.picture, +- name: userInfo.data.name +- }; ++ return userInfo.data; + } + /** + * A generic OAuth plugin that registers any OAuth/OIDC provider +@@ -107,8 +120,8 @@ const genericOAuth = (options) => { + let issuer; + let isOidc = false; + let idTokenConfig; +- if (c.discoveryUrl) { +- const discovered = await fetchDiscovery(c.discoveryUrl, c.discoveryHeaders, discoveryTimeout).catch((err) => { ++ if (c.discoveryUrl || c.discoveryDocument) { ++ const discovered = c.discoveryDocument ?? await fetchDiscovery(c.discoveryUrl, c.discoveryHeaders, discoveryTimeout).catch((err) => { + ctx.logger.error(`Discovery fetch failed for "${c.providerId}": ${err}`); + return null; + }); +diff --git a/dist/plugins/generic-oauth/types.d.mts b/dist/plugins/generic-oauth/types.d.mts +index 8109668..30839db 100644 +--- a/dist/plugins/generic-oauth/types.d.mts ++++ b/dist/plugins/generic-oauth/types.d.mts +@@ -40,6 +40,18 @@ export interface GenericOAuthConfig { + * If provided, the authorization and token endpoints will be fetched from this URL. + */ + discoveryUrl?: string | undefined; ++ /** Resolved metadata for an explicitly configured provider. Obtain and validate it ++ * before creating the auth instance; token signatures and claims remain verified ++ * against its issuer, audience, algorithms, and live JWKS endpoint. */ ++ discoveryDocument?: { ++ readonly issuer: string; ++ readonly authorization_endpoint: string; ++ readonly token_endpoint: string; ++ readonly userinfo_endpoint?: string; ++ readonly jwks_uri: string; ++ readonly id_token_signing_alg_values_supported: readonly string[]; ++ readonly end_session_endpoint?: string; ++ } | undefined; + /** + * Require discovery to provide the issuer and JWKS metadata needed to verify + * ID tokens before this provider is registered. +diff --git a/dist/crypto/index.mjs b/dist/crypto/index.mjs +--- a/dist/crypto/index.mjs ++++ b/dist/crypto/index.mjs +@@ -1,4 +1,5 @@ + import { signJWT, symmetricDecodeJWT, symmetricEncodeJWT, verifyJWT } from "./jwt.mjs"; ++import { derivePurposeKey } from "./purpose.mjs"; + import { constantTimeEqual } from "./buffer.mjs"; + import { hashPassword, verifyPassword } from "./password.mjs"; + import { generateRandomString } from "./random.mjs"; +@@ -67,4 +68,4 @@ + return btoa(String.fromCharCode(...new Uint8Array(signature))); + }; + //#endregion +-export { constantTimeEqual, formatEnvelope, generateRandomString, getCryptoKey, hashPassword, makeSignature, parseEnvelope, signJWT, symmetricDecodeJWT, symmetricDecrypt, symmetricEncodeJWT, symmetricEncrypt, verifyJWT, verifyPassword }; ++export { derivePurposeKey, constantTimeEqual, formatEnvelope, generateRandomString, getCryptoKey, hashPassword, makeSignature, parseEnvelope, signJWT, symmetricDecodeJWT, symmetricDecrypt, symmetricEncodeJWT, symmetricEncrypt, verifyJWT, verifyPassword }; +diff --git a/dist/crypto/index.d.mts b/dist/crypto/index.d.mts +--- a/dist/crypto/index.d.mts ++++ b/dist/crypto/index.d.mts +@@ -21,5 +21,8 @@ + export declare const symmetricDecrypt: ({ key, data }: SymmetricDecryptOptions) => Promise; + export declare const getCryptoKey: (secret: string | BufferSource) => Promise; + export declare const makeSignature: (value: string, secret: string | BufferSource) => Promise; ++/** HKDF-SHA256 key for one OAuth ciphertext purpose. A string secret becomes 32 bytes of hex. */ ++export declare function derivePurposeKey(secret: string, purpose: string): string; ++export declare function derivePurposeKey(secret: SecretConfig, purpose: string): SecretConfig; + //#endregion + export { type SecretConfig, constantTimeEqual, generateRandomString, hashPassword, signJWT, symmetricDecodeJWT, symmetricEncodeJWT, verifyJWT, verifyPassword }; +\ No newline at end of file diff --git a/patches/better-auth@1.7.5.patch b/patches/better-auth@1.7.5.patch deleted file mode 100644 index 68086a4e4a..0000000000 --- a/patches/better-auth@1.7.5.patch +++ /dev/null @@ -1,220 +0,0 @@ -diff --git a/dist/api/rate-limiter/index.mjs b/dist/api/rate-limiter/index.mjs -index 55b2705f9915dd611fb6d9a5e3b81d5cc47fb846..8caad90d67af19a9d18a937d96668e3ab072f6b5 100644 ---- a/dist/api/rate-limiter/index.mjs -+++ b/dist/api/rate-limiter/index.mjs -@@ -85,7 +85,11 @@ function createDatabaseStorageWrapper(ctx) { - value: key - }] - }))[0]; -- if (typeof data?.lastRequest === "bigint") data.lastRequest = Number(data.lastRequest); -+ if (typeof data?.lastRequest === "bigint" || typeof data?.lastRequest === "string") { -+ const lastRequest = Number(data.lastRequest); -+ if (!Number.isSafeInteger(lastRequest) || lastRequest < 0) throw new BetterAuthError("Invalid persisted rate-limit timestamp"); -+ data.lastRequest = lastRequest; -+ } - return data; - }; - const consume = async (key, rule) => { -diff --git a/dist/plugins/generic-oauth/index.mjs b/dist/plugins/generic-oauth/index.mjs -index 4643efd0..75a25d00 100644 ---- a/dist/plugins/generic-oauth/index.mjs -+++ b/dist/plugins/generic-oauth/index.mjs -@@ -35,28 +35,42 @@ - return result.data; - } - async function fetchUserInfo(tokens, userInfoUrl) { -+ let decoded; - if (tokens.idToken) try { -- const decoded = decodeJwt(tokens.idToken); -- if (decoded?.sub && decoded?.email) return { -- id: decoded.sub, -- emailVerified: decoded.email_verified, -- image: decoded.picture, -+ decoded = decodeJwt(tokens.idToken); -+ } catch {} -+ if (decoded?.sub && decoded?.email) { -+ const userInfo = decoded.email_verified === void 0 && userInfoUrl ? await requestUserInfo(tokens, userInfoUrl) : null; -+ const { id: _, ...userInfoClaims } = userInfo && userInfo.sub === decoded.sub ? userInfo : {}; -+ const claims = { -+ ...userInfoClaims, - ...decoded - }; -- } catch {} -+ return { -+ id: decoded.sub, -+ emailVerified: claims.email_verified ?? false, -+ image: claims.picture, -+ ...claims -+ }; -+ } - if (!userInfoUrl) return null; -+ const userInfo = await requestUserInfo(tokens, userInfoUrl); -+ if (!userInfo) return null; -+ return { -+ ...userInfo, -+ email: userInfo.email, -+ emailVerified: userInfo.email_verified ?? false, -+ image: userInfo.picture, -+ name: userInfo.name -+ }; -+} -+async function requestUserInfo(tokens, userInfoUrl) { - const userInfo = await betterFetch(userInfoUrl, { - method: "GET", - headers: { Authorization: `Bearer ${tokens.accessToken}` } - }); - if (userInfo.error || !userInfo.data) return null; -- return { -- ...userInfo.data, -- email: userInfo.data.email, -- emailVerified: userInfo.data.email_verified ?? false, -- image: userInfo.data.picture, -- name: userInfo.data.name -- }; -+ return userInfo.data; - } - /** - * A generic OAuth plugin that registers any OAuth/OIDC provider -@@ -87,8 +101,8 @@ - let issuer; - let isOidc = false; - let idTokenConfig; -- if (c.discoveryUrl) { -- const discovered = await fetchDiscovery(c.discoveryUrl, c.discoveryHeaders).catch((err) => { -+ if (c.discoveryUrl || c.discoveryDocument) { -+ const discovered = c.discoveryDocument ?? await fetchDiscovery(c.discoveryUrl, c.discoveryHeaders).catch((err) => { - ctx.logger.error(`Discovery fetch failed for "${c.providerId}": ${err}`); - return null; - }); -diff --git a/dist/plugins/generic-oauth/types.d.mts b/dist/plugins/generic-oauth/types.d.mts -index 96c8b20b..4591ab82 100644 ---- a/dist/plugins/generic-oauth/types.d.mts -+++ b/dist/plugins/generic-oauth/types.d.mts -@@ -41,6 +41,18 @@ interface GenericOAuthConfig { - * If provided, the authorization and token endpoints will be fetched from this URL. - */ - discoveryUrl?: string | undefined; -+ /** Resolved metadata for an explicitly configured provider. Obtain and validate it -+ * before creating the auth instance; token signatures and claims remain verified -+ * against its issuer, audience, algorithms, and live JWKS endpoint. */ -+ discoveryDocument?: { -+ readonly issuer: string; -+ readonly authorization_endpoint: string; -+ readonly token_endpoint: string; -+ readonly userinfo_endpoint?: string; -+ readonly jwks_uri: string; -+ readonly id_token_signing_alg_values_supported: readonly string[]; -+ readonly end_session_endpoint?: string; -+ } | undefined; - /** - * Require discovery to provide the issuer and JWKS metadata needed to verify - * ID tokens before this provider is registered. -diff --git a/dist/db/to-zod.mjs b/dist/db/to-zod.mjs -index f0e2b9c..5229800 100644 ---- a/dist/db/to-zod.mjs -+++ b/dist/db/to-zod.mjs -@@ -9,7 +9,12 @@ function toZodSchema({ fields, isClientSide }) { - if (field.type === "json") schema = z.json ? z.json() : z.any(); - else if (field.type === "string[]" || field.type === "number[]") schema = z.array(field.type === "string[]" ? z.string() : z.number()); - else if (Array.isArray(field.type)) schema = z.any(); -- else schema = z[field.type](); -+ else schema = { -+ string: z.string, -+ number: z.number, -+ boolean: z.boolean, -+ date: z.date -+ }[field.type](); - if (field?.required === false) schema = schema.nullish(); - if (!isClientSide && field?.returned === false) return acc; - return { -diff --git a/dist/plugins/admin/admin.d.mts b/dist/plugins/admin/admin.d.mts -index 03e33f2..7f69af7 100644 ---- a/dist/plugins/admin/admin.d.mts -+++ b/dist/plugins/admin/admin.d.mts -@@ -868,6 +868,7 @@ declare const admin: (options?: O | undefined) => { - INVALID_ROLE_TYPE: _$_better_auth_core_utils_error_codes0.RawError<"INVALID_ROLE_TYPE">; - YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL: _$_better_auth_core_utils_error_codes0.RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL">; - PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: _$_better_auth_core_utils_error_codes0.RawError<"PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER">; -+ USER_ID_CANNOT_BE_UPDATED: _$_better_auth_core_utils_error_codes0.RawError<"USER_ID_CANNOT_BE_UPDATED">; - }; - schema: { - user: { -diff --git a/dist/plugins/admin/admin.mjs b/dist/plugins/admin/admin.mjs -index b27646d..49ad91a 100644 ---- a/dist/plugins/admin/admin.mjs -+++ b/dist/plugins/admin/admin.mjs -@@ -22,7 +22,7 @@ const admin = (options) => { - return { - id: "admin", - version: PACKAGE_VERSION, -- init() { -+ init(authContext) { - return { options: { databaseHooks: { - user: { create: { async before(user) { - return { data: { -@@ -31,11 +31,11 @@ const admin = (options) => { - } }; - } } }, - session: { create: { async before(session, ctx) { -- if (!ctx) return; -- const user = await ctx.context.internalAdapter.findUserById(session.userId); -+ const internalAdapter = ctx?.context.internalAdapter ?? authContext.internalAdapter; -+ const user = await internalAdapter.findUserById(session.userId); - if (user?.banned) { - if (user.banExpires && new Date(user.banExpires).getTime() < Date.now()) { -- await ctx.context.internalAdapter.updateUser(session.userId, { -+ await internalAdapter.updateUser(session.userId, { - banned: false, - banReason: null, - banExpires: null -diff --git a/dist/plugins/admin/client.d.mts b/dist/plugins/admin/client.d.mts -index 921bb02..c0b1eb9 100644 ---- a/dist/plugins/admin/client.d.mts -+++ b/dist/plugins/admin/client.d.mts -@@ -75,6 +75,7 @@ declare const adminClient: (options?: O | undefine - INVALID_ROLE_TYPE: _$_better_auth_core_utils_error_codes0.RawError<"INVALID_ROLE_TYPE">; - YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL: _$_better_auth_core_utils_error_codes0.RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL">; - PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: _$_better_auth_core_utils_error_codes0.RawError<"PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER">; -+ USER_ID_CANNOT_BE_UPDATED: _$_better_auth_core_utils_error_codes0.RawError<"USER_ID_CANNOT_BE_UPDATED">; - }; - }; - //#endregion -diff --git a/dist/plugins/admin/error-codes.d.mts b/dist/plugins/admin/error-codes.d.mts -index 0906710..18b1d9c 100644 ---- a/dist/plugins/admin/error-codes.d.mts -+++ b/dist/plugins/admin/error-codes.d.mts -@@ -25,6 +25,7 @@ declare const ADMIN_ERROR_CODES: { - INVALID_ROLE_TYPE: _$_better_auth_core_utils_error_codes0.RawError<"INVALID_ROLE_TYPE">; - YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL: _$_better_auth_core_utils_error_codes0.RawError<"YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL">; - PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: _$_better_auth_core_utils_error_codes0.RawError<"PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER">; -+ USER_ID_CANNOT_BE_UPDATED: _$_better_auth_core_utils_error_codes0.RawError<"USER_ID_CANNOT_BE_UPDATED">; - }; - //#endregion - export { ADMIN_ERROR_CODES }; -\ No newline at end of file -diff --git a/dist/plugins/admin/error-codes.mjs b/dist/plugins/admin/error-codes.mjs -index 6f2639d..6f55147 100644 ---- a/dist/plugins/admin/error-codes.mjs -+++ b/dist/plugins/admin/error-codes.mjs -@@ -23,7 +23,8 @@ const ADMIN_ERROR_CODES = defineErrorCodes({ - YOU_CANNOT_IMPERSONATE_ADMINS: "You cannot impersonate admins", - INVALID_ROLE_TYPE: "Invalid role type", - YOU_ARE_NOT_ALLOWED_TO_SET_USERS_EMAIL: "You are not allowed to update users email", -- PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: "Password cannot be updated through update-user. Use the set-user-password endpoint instead" -+ PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER: "Password cannot be updated through update-user. Use the set-user-password endpoint instead", -+ USER_ID_CANNOT_BE_UPDATED: "User id cannot be updated" - }); - //#endregion - export { ADMIN_ERROR_CODES }; -diff --git a/dist/plugins/admin/routes.mjs b/dist/plugins/admin/routes.mjs -index 126a99b..eb4859b 100644 ---- a/dist/plugins/admin/routes.mjs -+++ b/dist/plugins/admin/routes.mjs -@@ -255,6 +255,7 @@ const adminUpdateUser = (opts) => createAuthEndpoint("/admin/update-user", { - const updateData = ctx.body.data; - const hasDataKey = (key) => Object.prototype.hasOwnProperty.call(updateData, key); - if (hasDataKey("password")) throw APIError.from("BAD_REQUEST", ADMIN_ERROR_CODES.PASSWORD_CANNOT_BE_UPDATED_VIA_UPDATE_USER); -+ if (hasDataKey("id")) throw APIError.from("BAD_REQUEST", ADMIN_ERROR_CODES.USER_ID_CANNOT_BE_UPDATED); - if (Object.prototype.hasOwnProperty.call(ctx.body.data, "role")) { - if (!hasPermission({ - userId: ctx.context.session.user.id,