From 3e1d3ab6c8115fd1acfa62cd6c32a2eeb0fee4a8 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 10 Jul 2026 11:09:26 +0900 Subject: [PATCH 01/11] feat(resolver): add `auth` field to reject anonymous callers `createResolver` had no declarative auth mechanism, so a resolver with no in-body check was reachable by an anonymous caller by default. Add an optional `auth: "loggedIn" | "public"` field: "loggedIn" rejects anonymous callers (based on `context.user`, unaffected by `authInvoker`) before `body` runs; "public" documents that anonymous access is intentional. Omitting `auth` keeps prior behavior unchanged. --- .changeset/resolver-auth-field.md | 5 ++++ packages/sdk/docs/services/resolver.md | 27 +++++++++++++++++ .../src/cli/services/resolver/bundler.test.ts | 29 +++++++++++++++++++ .../sdk/src/cli/services/resolver/bundler.ts | 3 ++ .../services/resolver/resolver.test.ts | 15 ++++++++++ .../configure/services/resolver/resolver.ts | 5 ++++ .../sdk/src/parser/service/resolver/schema.ts | 12 ++++++++ packages/sdk/src/types/resolver.generated.ts | 2 ++ 8 files changed, 98 insertions(+) create mode 100644 .changeset/resolver-auth-field.md diff --git a/.changeset/resolver-auth-field.md b/.changeset/resolver-auth-field.md new file mode 100644 index 0000000000..1276b0370b --- /dev/null +++ b/.changeset/resolver-auth-field.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add `auth` field to `createResolver` for declaring a resolver's access requirement. Set `auth: "loggedIn"` to reject anonymous callers before `body` runs, or `auth: "public"` to explicitly document that anonymous callers are allowed. Omitting `auth` keeps prior behavior unchanged. diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index cdc6ac61fa..8c5e3648db 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -352,6 +352,33 @@ createResolver({ ## Authentication +### Requiring a Logged-In Caller (`auth`) + +By default, a resolver with no in-body check is reachable by an anonymous (unauthenticated) caller. Set `auth: "loggedIn"` to reject anonymous callers before `body` runs: + +```typescript +import { createResolver, t } from "@tailor-platform/sdk"; + +export default createResolver({ + name: "getMyOrders", + operation: "query", + auth: "loggedIn", + output: t.object({ count: t.int() }), + body: async (context) => { + // context.user is guaranteed to be an authenticated caller here + return { count: 0 }; + }, +}); +``` + +- `auth: "loggedIn"` — anonymous callers get a `TailorErrorMessage` and `body` does not run. +- `auth: "public"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `auth`, but records the decision so it isn't mistaken for an oversight. +- Omitted (default) — unchanged: anonymous callers can still reach the resolver. + +This check is based on `context.user`, the original caller, so it still applies even when `authInvoker` swaps in a machine user for database access. + +### Running as a Machine User (`authInvoker`) + Specify an `authInvoker` to execute the resolver with machine user credentials. Pass the machine user name as a plain string — it is type-narrowed to the names you defined in your auth config: ```typescript diff --git a/packages/sdk/src/cli/services/resolver/bundler.test.ts b/packages/sdk/src/cli/services/resolver/bundler.test.ts index 1e7bb56b9f..b6ae9f75a5 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.test.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.test.ts @@ -42,6 +42,35 @@ describe("bundleResolvers", () => { ).resolves.toEqual(new Map()); }); + test("injects the loggedIn auth guard into the entry file", async () => { + using tmp = tempCwd("sdk-bundler-auth-"); + const resolverDir = path.join(tmp.dir, "src/backend/authcheck/resolver"); + fs.mkdirSync(resolverDir, { recursive: true }); + fs.writeFileSync( + path.join(resolverDir, "protected.ts"), + `export default {\n` + + ` operation: "query",\n` + + ` name: "protected",\n` + + ` auth: "loggedIn",\n` + + ` body: async () => 1,\n` + + ` output: { type: "integer", metadata: {}, fields: {} },\n` + + `};\n`, + ); + + await bundleResolvers("authcheck", { + files: ["./src/backend/authcheck/resolver/*.ts"], + }); + + const entryContent = fs.readFileSync( + path.join(tmp.dir, ".tailor-sdk/resolvers/protected.entry.js"), + "utf-8", + ); + + expect(entryContent).toContain('_internalResolver.auth === "loggedIn"'); + expect(entryContent).toContain("!context.user.type"); + expect(entryContent).toContain("This resolver requires an authenticated caller."); + }); + describe("concurrency", () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/packages/sdk/src/cli/services/resolver/bundler.ts b/packages/sdk/src/cli/services/resolver/bundler.ts index 0eab379b87..e563ba7800 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.ts @@ -155,6 +155,9 @@ async function bundleSingleResolver( const $tailor_resolver_body = async (context) => { const invoker = ${INVOKER_EXPR}; + if (_internalResolver.auth === "loggedIn" && !context.user.type) { + throw new TailorErrorMessage("This resolver requires an authenticated caller."); + } if (_internalResolver.input) { const result = t.object(_internalResolver.input).parse({ value: context.input, diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 063c1e19cb..3d34b4b1cd 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -465,6 +465,20 @@ describe("createResolver", () => { expect(resolver.authInvoker).toEqual({ namespace: "my-auth", machineUserName: "batch-user" }); }); + test("creates resolver with auth: loggedIn", () => { + const outputType = t.object({ result: t.string() }); + + const resolver = createResolver({ + name: "withAuthLoggedIn", + operation: "query", + output: outputType, + body: () => ({ result: "ok" }), + auth: "loggedIn", + }); + + expect(resolver.auth).toBe("loggedIn"); + }); + test("creates minimal resolver without optional fields", () => { const outputType = t.object({ result: t.string() }); @@ -480,6 +494,7 @@ describe("createResolver", () => { expect(resolver.output).toBe(outputType); expect(resolver.description).toBeUndefined(); expect(resolver.input).toBeUndefined(); + expect(resolver.auth).toBeUndefined(); }); test("accepts Record as output and converts to t.object()", () => { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 24db05630c..2c42e71cb0 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -57,6 +57,11 @@ type ResolverReturn< * If not specified, this is automatically set to true when an executor uses this resolver * with `resolverExecutedTrigger`. If explicitly set to false while an executor uses this * resolver, an error will be thrown during apply. + * + * `auth` declares the resolver's access requirement. Omitted (default): unchanged, anonymous + * callers can reach the resolver. `"loggedIn"`: anonymous callers are rejected before `body` + * runs. `"public"`: explicitly documents that anonymous callers are allowed. The check is + * based on `context.user`, so it is unaffected by `authInvoker`. * @template Input * @template Output * @param config - Resolver configuration diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index e909bbff52..88e0a0b359 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -7,6 +7,13 @@ export const QueryTypeSchema = z .union([z.literal("query"), z.literal("mutation")]) .describe("GraphQL operation type"); +export const ResolverAuthSchema = z + .union([z.literal("loggedIn"), z.literal("public")]) + .describe( + 'Access requirement for this resolver: "loggedIn" rejects anonymous callers before ' + + '`body` runs; "public" documents that anonymous callers are allowed', + ); + export const ResolverSchema = z.object({ operation: QueryTypeSchema.describe("GraphQL operation type (query or mutation)"), name: z.string().describe("Resolver name"), @@ -16,4 +23,9 @@ export const ResolverSchema = z.object({ output: TailorFieldSchema.describe("Output field definition"), publishEvents: z.boolean().optional().describe("Enable publishing events from this resolver"), authInvoker: AuthInvokerSchema.optional().describe("Machine user to execute this resolver as"), + auth: ResolverAuthSchema.optional().describe( + "Access requirement for this resolver. Omitted (default): unchanged, anonymous callers " + + 'can reach the resolver. "loggedIn": anonymous callers are rejected before `body` ' + + 'runs. "public": explicitly documents that anonymous callers are allowed', + ), }); diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index de57d64512..932c2bdbf2 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -63,5 +63,7 @@ export type Resolver = { machineUserName: string; } | undefined; + /** Access requirement for this resolver. Omitted (default): unchanged, anonymous callers can reach the resolver. "loggedIn": anonymous callers are rejected before `body` runs. "public": explicitly documents that anonymous callers are allowed */ + auth?: "loggedIn" | "public" | undefined; }; export type ResolverInput = Resolver; From abab8a99048830a1e0392749a864a251f49bb8d0 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 10 Jul 2026 14:06:13 +0900 Subject: [PATCH 02/11] feat(resolver): redesign auth as gqlPermission-style conditions Replace the "loggedIn" | "public" enum with a declarative `{ conditions, permit }` policy, matching TailorDB's `.permission()` notation restricted to `user` operands (a resolver has no associated record) with equality (`=`/`!=`) comparisons only. Supports the `_loggedIn`, `id`, and arbitrary user-attribute operands. The bundler compiles the conditions into a JS boolean guard at bundle time and injects it into the resolver entry wrapper, still evaluated against `context.user` (unaffected by `authInvoker`) before `body` runs. `permit` is required (no implicit allow/deny default) since a silently inverted default is actively dangerous for a login gate. `auth: "public"` remains as an explicit opt-out marker. --- .changeset/resolver-auth-field.md | 2 +- packages/sdk/docs/services/resolver.md | 19 +++- .../src/cli/services/resolver/bundler.test.ts | 37 +++++++- .../sdk/src/cli/services/resolver/bundler.ts | 10 +- .../sdk/src/cli/shared/runtime-exprs.test.ts | 91 ++++++++++++++++++- packages/sdk/src/cli/shared/runtime-exprs.ts | 56 ++++++++++++ .../src/configure/services/resolver/index.ts | 1 + .../configure/services/resolver/permission.ts | 58 ++++++++++++ .../services/resolver/resolver.test.ts | 23 ++++- .../configure/services/resolver/resolver.ts | 17 ++-- .../sdk/src/parser/service/resolver/schema.ts | 38 ++++++-- packages/sdk/src/types/resolver.generated.ts | 45 ++++++++- 12 files changed, 362 insertions(+), 35 deletions(-) create mode 100644 packages/sdk/src/configure/services/resolver/permission.ts diff --git a/.changeset/resolver-auth-field.md b/.changeset/resolver-auth-field.md index 1276b0370b..05870574f7 100644 --- a/.changeset/resolver-auth-field.md +++ b/.changeset/resolver-auth-field.md @@ -2,4 +2,4 @@ "@tailor-platform/sdk": minor --- -Add `auth` field to `createResolver` for declaring a resolver's access requirement. Set `auth: "loggedIn"` to reject anonymous callers before `body` runs, or `auth: "public"` to explicitly document that anonymous callers are allowed. Omitting `auth` keeps prior behavior unchanged. +Add `auth` field to `createResolver` for declaring a resolver's access requirement, using the same `conditions`/`permit` notation as TailorDB's `.permission()` (restricted to `user` operands, e.g. `auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }`). Rejects non-matching callers before `body` runs. `auth: "public"` explicitly documents that anonymous callers are allowed. Omitting `auth` keeps prior behavior unchanged. diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index 8c5e3648db..c091bc30e2 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -352,9 +352,9 @@ createResolver({ ## Authentication -### Requiring a Logged-In Caller (`auth`) +### Access Requirement (`auth`) -By default, a resolver with no in-body check is reachable by an anonymous (unauthenticated) caller. Set `auth: "loggedIn"` to reject anonymous callers before `body` runs: +By default, a resolver with no in-body check is reachable by an anonymous (unauthenticated) caller. Set `auth` to reject callers that don't match a condition, evaluated before `body` runs: ```typescript import { createResolver, t } from "@tailor-platform/sdk"; @@ -362,7 +362,7 @@ import { createResolver, t } from "@tailor-platform/sdk"; export default createResolver({ name: "getMyOrders", operation: "query", - auth: "loggedIn", + auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, output: t.object({ count: t.int() }), body: async (context) => { // context.user is guaranteed to be an authenticated caller here @@ -371,8 +371,17 @@ export default createResolver({ }); ``` -- `auth: "loggedIn"` — anonymous callers get a `TailorErrorMessage` and `body` does not run. -- `auth: "public"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `auth`, but records the decision so it isn't mistaken for an oversight. +`auth` uses the same `conditions`/`permit` notation as TailorDB's `.permission()`, restricted to `user` operands (a resolver has no associated record to compare against) with equality (`=`/`!=`) comparisons: + +- `{ user: "_loggedIn" }` — whether the caller is authenticated +- `{ user: "id" }` — the caller's user ID +- `{ user: "someAttribute" }` — any attribute enabled in `auth.userProfile.attributes` + +Multiple conditions in the same `conditions` array are combined with AND. `permit` is required — `permit: true` denies callers that _don't_ match, `permit: false` denies callers that _do_ match. + +Besides a conditions object, `auth` also accepts: + +- `"public"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `auth`, but records the decision so it isn't mistaken for an oversight. - Omitted (default) — unchanged: anonymous callers can still reach the resolver. This check is based on `context.user`, the original caller, so it still applies even when `authInvoker` swaps in a machine user for database access. diff --git a/packages/sdk/src/cli/services/resolver/bundler.test.ts b/packages/sdk/src/cli/services/resolver/bundler.test.ts index b6ae9f75a5..5017655d08 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.test.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.test.ts @@ -42,7 +42,7 @@ describe("bundleResolvers", () => { ).resolves.toEqual(new Map()); }); - test("injects the loggedIn auth guard into the entry file", async () => { + test("injects the auth guard into the entry file", async () => { using tmp = tempCwd("sdk-bundler-auth-"); const resolverDir = path.join(tmp.dir, "src/backend/authcheck/resolver"); fs.mkdirSync(resolverDir, { recursive: true }); @@ -51,7 +51,7 @@ describe("bundleResolvers", () => { `export default {\n` + ` operation: "query",\n` + ` name: "protected",\n` + - ` auth: "loggedIn",\n` + + ` auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true },\n` + ` body: async () => 1,\n` + ` output: { type: "integer", metadata: {}, fields: {} },\n` + `};\n`, @@ -66,9 +66,36 @@ describe("bundleResolvers", () => { "utf-8", ); - expect(entryContent).toContain('_internalResolver.auth === "loggedIn"'); - expect(entryContent).toContain("!context.user.type"); - expect(entryContent).toContain("This resolver requires an authenticated caller."); + expect(entryContent).toContain('context.user.type !== ""'); + expect(entryContent).toContain("TailorErrorMessage"); + expect(entryContent).toContain("access denied"); + }); + + test("does not inject a guard when auth is omitted or public", async () => { + using tmp = tempCwd("sdk-bundler-noauth-"); + const resolverDir = path.join(tmp.dir, "src/backend/noauth/resolver"); + fs.mkdirSync(resolverDir, { recursive: true }); + fs.writeFileSync( + path.join(resolverDir, "open.ts"), + `export default {\n` + + ` operation: "query",\n` + + ` name: "open",\n` + + ` auth: "public",\n` + + ` body: async () => 1,\n` + + ` output: { type: "integer", metadata: {}, fields: {} },\n` + + `};\n`, + ); + + await bundleResolvers("noauth", { + files: ["./src/backend/noauth/resolver/*.ts"], + }); + + const entryContent = fs.readFileSync( + path.join(tmp.dir, ".tailor-sdk/resolvers/open.entry.js"), + "utf-8", + ); + + expect(entryContent).not.toContain("TailorErrorMessage"); }); describe("concurrency", () => { diff --git a/packages/sdk/src/cli/services/resolver/bundler.ts b/packages/sdk/src/cli/services/resolver/bundler.ts index e563ba7800..a12c344e68 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.ts @@ -11,7 +11,7 @@ import { getDistDir } from "#/cli/shared/dist-dir"; import { composeFunctionTreeshakeOptions } from "#/cli/shared/function-treeshake"; import { logger, styles } from "#/cli/shared/logger"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; -import { INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; +import { buildResolverAuthGuardExpr, INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; import { createTriggerTransformPlugin, serializeTriggerContext, @@ -20,10 +20,12 @@ import { import ml from "#/utils/multiline"; import { loadResolver } from "./loader"; import type { LogLevel } from "#/configure/config/types"; +import type { Resolver } from "#/types/resolver.generated"; interface ResolverInfo { name: string; sourceFile: string; + auth: Resolver["auth"]; } /** @@ -74,6 +76,7 @@ export async function bundleResolvers( resolvers.push({ name: resolver.name, sourceFile: file, + auth: resolver.auth, }); } @@ -148,6 +151,7 @@ async function bundleSingleResolver( // Step 1: Create entry file that imports from the original source const entryPath = path.join(outputDir, `${resolver.name}.entry.js`); const absoluteSourcePath = path.resolve(resolver.sourceFile); + const authGuardExpr = buildResolverAuthGuardExpr(resolver.auth); const entryContent = ml /* js */ ` import _internalResolver from "${absoluteSourcePath}"; @@ -155,9 +159,7 @@ async function bundleSingleResolver( const $tailor_resolver_body = async (context) => { const invoker = ${INVOKER_EXPR}; - if (_internalResolver.auth === "loggedIn" && !context.user.type) { - throw new TailorErrorMessage("This resolver requires an authenticated caller."); - } + ${authGuardExpr ?? ""} if (_internalResolver.input) { const result = t.object(_internalResolver.input).parse({ value: context.input, diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index 6a8e0ce488..9c8b3cc4b1 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -1,5 +1,9 @@ import { describe, test, expect } from "vitest"; -import { buildExecutorArgsExpr, buildResolverOperationHookExpr } from "./runtime-exprs"; +import { + buildExecutorArgsExpr, + buildResolverAuthGuardExpr, + buildResolverOperationHookExpr, +} from "./runtime-exprs"; describe("buildExecutorArgsExpr", () => { const env = { API_URL: "https://example.com", DEBUG: true }; @@ -103,3 +107,88 @@ describe("buildResolverOperationHookExpr", () => { expect(expr).toContain("env: {}"); }); }); + +describe("buildResolverAuthGuardExpr", () => { + class TailorErrorMessage extends Error {} + + function runGuard(auth: Parameters[0], user: unknown): void { + const guard = buildResolverAuthGuardExpr(auth); + if (!guard) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const fn = new Function("context", "TailorErrorMessage", guard); + fn({ user }, TailorErrorMessage); + } + + test("returns undefined when auth is omitted", () => { + expect(buildResolverAuthGuardExpr(undefined)).toBeUndefined(); + }); + + test("returns undefined when auth is public", () => { + expect(buildResolverAuthGuardExpr("public")).toBeUndefined(); + }); + + test("_loggedIn permit:true allows an authenticated user", () => { + const auth = { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true } as const; + expect(() => runGuard(auth, { type: "user" })).not.toThrow(); + }); + + test("_loggedIn permit:true rejects an anonymous user", () => { + const auth = { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true } as const; + expect(() => runGuard(auth, { type: "" })).toThrow(TailorErrorMessage); + }); + + test("permit:false denies matching callers instead of allowing them", () => { + const auth = { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: false } as const; + expect(() => runGuard(auth, { type: "user" })).toThrow(TailorErrorMessage); + expect(() => runGuard(auth, { type: "" })).not.toThrow(); + }); + + test("supports the != operator", () => { + const auth = { conditions: [[{ user: "role" }, "!=", "BANNED"]], permit: true } as const; + expect(() => runGuard(auth, { attributes: { role: "MEMBER" } })).not.toThrow(); + expect(() => runGuard(auth, { attributes: { role: "BANNED" } })).toThrow(TailorErrorMessage); + }); + + test("supports the id operand", () => { + const auth = { + conditions: [[{ user: "id" }, "=", "11111111-1111-1111-1111-111111111111"]], + permit: true, + } as const; + expect(() => runGuard(auth, { id: "11111111-1111-1111-1111-111111111111" })).not.toThrow(); + expect(() => runGuard(auth, { id: "other" })).toThrow(TailorErrorMessage); + }); + + test("supports arbitrary user attribute operands", () => { + const auth = { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true } as const; + expect(() => runGuard(auth, { attributes: { role: "ADMIN" } })).not.toThrow(); + expect(() => runGuard(auth, { attributes: { role: "MEMBER" } })).toThrow(TailorErrorMessage); + }); + + test("ANDs multiple conditions", () => { + const auth = { + conditions: [ + [{ user: "_loggedIn" }, "=", true], + [{ user: "role" }, "=", "ADMIN"], + ], + permit: true, + } as const; + expect(() => runGuard(auth, { type: "user", attributes: { role: "ADMIN" } })).not.toThrow(); + expect(() => runGuard(auth, { type: "user", attributes: { role: "MEMBER" } })).toThrow( + TailorErrorMessage, + ); + expect(() => runGuard(auth, { type: "", attributes: { role: "ADMIN" } })).toThrow( + TailorErrorMessage, + ); + }); + + test("includes description in the thrown message when present", () => { + const auth = { + conditions: [[{ user: "_loggedIn" }, "=", true]], + permit: true, + description: "must be logged in", + } as const; + expect(() => runGuard(auth, { type: "" })).toThrow(/must be logged in/); + }); +}); diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index 056c785660..36726d2764 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -12,6 +12,7 @@ */ import { tailorUserMap } from "#/parser/service/tailordb/index"; import type { Trigger } from "#/types/executor.generated"; +import type { Resolver } from "#/types/resolver.generated"; // --------------------------------------------------------------------------- // Bundle inline @@ -102,3 +103,58 @@ export function buildResolverOperationHookExpr( ): string { return `({ ...context.pipeline, input: context.args, user: ${tailorUserMap}, env: ${JSON.stringify(env)} });`; } + +type ResolverAuthPolicy = Extract, { conditions: unknown }>; +type ResolverPermissionOperand = string | boolean | { user: string }; +type ResolverPermissionCondition = readonly [ + ResolverPermissionOperand, + "=" | "!=", + ResolverPermissionOperand, +]; + +function isSingleResolverCondition( + conditions: ResolverAuthPolicy["conditions"], +): conditions is ResolverPermissionCondition { + return conditions.length === 3 && typeof conditions[1] === "string"; +} + +function resolverPermissionOperandExpr(operand: ResolverPermissionOperand): string { + if (typeof operand === "object") { + if (operand.user === "_loggedIn") { + return `(context.user.type !== "")`; + } + if (operand.user === "id") { + return `context.user.id`; + } + return `context.user.attributes?.[${JSON.stringify(operand.user)}]`; + } + return JSON.stringify(operand); +} + +function resolverPermissionConditionExpr(condition: ResolverPermissionCondition): string { + const [left, operator, right] = condition; + const jsOperator = operator === "=" ? "===" : "!=="; + return `(${resolverPermissionOperandExpr(left)} ${jsOperator} ${resolverPermissionOperandExpr(right)})`; +} + +/** + * Build the auth guard statement injected at resolver entry. + * + * Rejects the call with `TailorErrorMessage` when the caller doesn't match + * `auth`'s conditions, evaluated against `context.user` — the original caller, + * unaffected by `authInvoker`. + * @param auth - The resolver's `auth` config + * @returns A JS `if (...) throw ...;` statement, or `undefined` when `auth` is omitted or `"public"` + */ +export function buildResolverAuthGuardExpr(auth: Resolver["auth"]): string | undefined { + if (!auth || auth === "public") { + return undefined; + } + const conditions = isSingleResolverCondition(auth.conditions) + ? [auth.conditions] + : auth.conditions; + const combined = conditions.map(resolverPermissionConditionExpr).join(" && "); + const denyExpr = auth.permit === false ? `(${combined})` : `!(${combined})`; + const message = auth.description ? `access denied: ${auth.description}` : "access denied"; + return `if (${denyExpr}) { throw new TailorErrorMessage(${JSON.stringify(message)}); }`; +} diff --git a/packages/sdk/src/configure/services/resolver/index.ts b/packages/sdk/src/configure/services/resolver/index.ts index a66973b79a..924478ddc0 100644 --- a/packages/sdk/src/configure/services/resolver/index.ts +++ b/packages/sdk/src/configure/services/resolver/index.ts @@ -2,6 +2,7 @@ export { createResolver } from "./resolver"; export type { Resolver } from "#/types/resolver.generated"; export type { QueryType } from "./types"; +export type { ResolverPermission, ResolverPermissionCondition } from "./permission"; export type { ResolverServiceConfig, ResolverExternalConfig, diff --git a/packages/sdk/src/configure/services/resolver/permission.ts b/packages/sdk/src/configure/services/resolver/permission.ts new file mode 100644 index 0000000000..91a0371d72 --- /dev/null +++ b/packages/sdk/src/configure/services/resolver/permission.ts @@ -0,0 +1,58 @@ +import type { InferredAttributeMap } from "#/runtime/types"; + +type EqualityOperator = "=" | "!="; + +type StringFieldKeys = { + [K in keyof User]: User[K] extends string ? K : never; +}[keyof User]; + +type BooleanFieldKeys = { + [K in keyof User]: User[K] extends boolean ? K : never; +}[keyof User]; + +type UserStringOperand = { + user: StringFieldKeys | "id"; +}; + +type UserBooleanOperand = { + user: BooleanFieldKeys | "_loggedIn"; +}; + +type StringEqualityCondition = + | readonly [UserStringOperand, EqualityOperator, string] + | readonly [string, EqualityOperator, UserStringOperand]; + +type BooleanEqualityCondition = + | readonly [UserBooleanOperand, EqualityOperator, boolean] + | readonly [boolean, EqualityOperator, UserBooleanOperand]; + +/** + * A single condition for {@link ResolverPermission}. + * + * Only `user` operands are supported (unlike TailorDB's `record`/`newRecord`/ + * `oldRecord` operands) — a resolver has no associated record to compare + * against. Only equality (`=`/`!=`) is supported for now. + * + * The User type is extended by `tailor.d.ts`, which is automatically generated + * when running `tailor-sdk generate`. Attributes enabled in the config file's + * `auth.userProfile.attributes` become available as types. + */ +export type ResolverPermissionCondition = + | StringEqualityCondition + | BooleanEqualityCondition; + +/** + * Access requirement for a resolver, evaluated against the original caller + * (`context.user`) before `body` runs — unaffected by `authInvoker`. + * @example + * const auth: ResolverPermission = { + * conditions: [[{ user: "_loggedIn" }, "=", true]], + * permit: true, + * }; + */ +export type ResolverPermission = { + conditions: ResolverPermissionCondition | readonly ResolverPermissionCondition[]; + /** Whether matching callers are granted (`true`) or denied (`false`) access. */ + permit: boolean; + description?: string; +}; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 3d34b4b1cd..ba2b448fe7 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -465,7 +465,7 @@ describe("createResolver", () => { expect(resolver.authInvoker).toEqual({ namespace: "my-auth", machineUserName: "batch-user" }); }); - test("creates resolver with auth: loggedIn", () => { + test("creates resolver with loggedIn auth condition", () => { const outputType = t.object({ result: t.string() }); const resolver = createResolver({ @@ -473,10 +473,27 @@ describe("createResolver", () => { operation: "query", output: outputType, body: () => ({ result: "ok" }), - auth: "loggedIn", + auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, }); - expect(resolver.auth).toBe("loggedIn"); + expect(resolver.auth).toEqual({ + conditions: [[{ user: "_loggedIn" }, "=", true]], + permit: true, + }); + }); + + test("creates resolver with auth: public", () => { + const outputType = t.object({ result: t.string() }); + + const resolver = createResolver({ + name: "withAuthPublic", + operation: "query", + output: outputType, + body: () => ({ result: "ok" }), + auth: "public", + }); + + expect(resolver.auth).toBe("public"); }); test("creates minimal resolver without optional fields", () => { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 2c42e71cb0..788785159e 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -1,6 +1,7 @@ import { t, type TailorAnyField, type TailorField } from "#/configure/types/type"; import { brandValue } from "#/utils/brand"; import type { AuthInvoker } from "#/configure/services/auth/index"; +import type { ResolverPermission } from "#/configure/services/resolver/permission"; import type { MachineUserName } from "#/configure/types/machine-user"; import type { TailorEnv, TailorInvoker, TailorUser } from "#/runtime/types"; import type { InferFieldsOutput, output } from "#/types/helpers"; @@ -35,12 +36,13 @@ type NormalizedOutput | undefined, Output extends TailorAnyField | Record, -> = Omit & +> = Omit & Readonly<{ input?: Input; output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; + auth?: ResolverPermission | "public"; }>; /** @@ -58,10 +60,11 @@ type ResolverReturn< * with `resolverExecutedTrigger`. If explicitly set to false while an executor uses this * resolver, an error will be thrown during apply. * - * `auth` declares the resolver's access requirement. Omitted (default): unchanged, anonymous - * callers can reach the resolver. `"loggedIn"`: anonymous callers are rejected before `body` - * runs. `"public"`: explicitly documents that anonymous callers are allowed. The check is - * based on `context.user`, so it is unaffected by `authInvoker`. + * `auth` declares the resolver's access requirement, checked against `context.user` (the + * original caller, unaffected by `authInvoker`) before `body` runs. Omitted (default): + * unchanged, anonymous callers can reach the resolver. `"public"`: explicitly documents that + * anonymous callers are allowed. A `{ conditions, permit }` policy (in the same style as + * TailorDB's `.permission()`) rejects callers that don't match. * @template Input * @template Output * @param config - Resolver configuration @@ -72,6 +75,7 @@ type ResolverReturn< * export default createResolver({ * name: "getUser", * operation: "query", + * auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, * input: { * id: t.string(), * }, @@ -91,12 +95,13 @@ export function createResolver< Input extends Record | undefined = undefined, Output extends TailorAnyField | Record = TailorAnyField, >( - config: Omit & + config: Omit & Readonly<{ input?: Input; output: Output; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; + auth?: ResolverPermission | "public"; }>, ): ResolverReturn { // Check if output is already a TailorField using duck typing. diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index 88e0a0b359..ea88778f50 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -7,11 +7,37 @@ export const QueryTypeSchema = z .union([z.literal("query"), z.literal("mutation")]) .describe("GraphQL operation type"); +const ResolverPermissionOperandSchema = z.union([ + z.object({ user: z.string() }).strict(), + z.string(), + z.boolean(), +]); + +const ResolverPermissionOperatorSchema = z.union([z.literal("="), z.literal("!=")]); + +const ResolverPermissionConditionSchema = z + .tuple([ + ResolverPermissionOperandSchema, + ResolverPermissionOperatorSchema, + ResolverPermissionOperandSchema, + ]) + .readonly(); + +const ResolverPermissionSchema = z.object({ + conditions: z.union([ + ResolverPermissionConditionSchema, + z.array(ResolverPermissionConditionSchema).readonly(), + ]), + permit: z.boolean(), + description: z.string().optional(), +}); + export const ResolverAuthSchema = z - .union([z.literal("loggedIn"), z.literal("public")]) + .union([ResolverPermissionSchema, z.literal("public")]) .describe( - 'Access requirement for this resolver: "loggedIn" rejects anonymous callers before ' + - '`body` runs; "public" documents that anonymous callers are allowed', + "Access requirement for this resolver, evaluated against the original caller " + + '(unaffected by `authInvoker`) before `body` runs. "public" documents that anonymous ' + + "callers are allowed. Omitted (default): unchanged, anonymous callers can reach the resolver", ); export const ResolverSchema = z.object({ @@ -23,9 +49,5 @@ export const ResolverSchema = z.object({ output: TailorFieldSchema.describe("Output field definition"), publishEvents: z.boolean().optional().describe("Enable publishing events from this resolver"), authInvoker: AuthInvokerSchema.optional().describe("Machine user to execute this resolver as"), - auth: ResolverAuthSchema.optional().describe( - "Access requirement for this resolver. Omitted (default): unchanged, anonymous callers " + - 'can reach the resolver. "loggedIn": anonymous callers are rejected before `body` ' + - 'runs. "public": explicitly documents that anonymous callers are allowed', - ), + auth: ResolverAuthSchema.optional(), }); diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index 932c2bdbf2..36252bf7be 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -63,7 +63,48 @@ export type Resolver = { machineUserName: string; } | undefined; - /** Access requirement for this resolver. Omitted (default): unchanged, anonymous callers can reach the resolver. "loggedIn": anonymous callers are rejected before `body` runs. "public": explicitly documents that anonymous callers are allowed */ - auth?: "loggedIn" | "public" | undefined; + auth?: + | "public" + | { + conditions: + | readonly [ + ( + | string + | boolean + | { + user: string; + } + ), + "=" | "!=", + ( + | string + | boolean + | { + user: string; + } + ), + ] + | readonly (readonly [ + ( + | string + | boolean + | { + user: string; + } + ), + "=" | "!=", + ( + | string + | boolean + | { + user: string; + } + ), + ])[]; + permit: boolean; + /** Resolver description */ + description?: string | undefined; + } + | undefined; }; export type ResolverInput = Resolver; From 220053435f26a91e6a94ac3f3c433d14b7871302 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 10 Jul 2026 14:29:01 +0900 Subject: [PATCH 03/11] feat(resolver): support multiple auth policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change `auth` to always be an array of `{ conditions, permit }` policies (matching gqlPermission's convention of always wrapping policies in an array), so a resolver can express multiple eligibility paths — e.g. allow machine-user callers unconditionally via an attribute check, while gating regular users behind a role condition. A single-condition case just means a one-element array. Policies combine like an allow-list with an explicit-deny override: a `permit: false` policy always denies matching callers; with no `permit: true` policy, `auth` is a pure blocklist (everyone else is allowed), and with at least one, it becomes an allow-list (denied by default, granted only by a matching `permit: true` policy). This also fixes a bug in the initial single-policy version where a deny-only policy (no allow policies at all) would deny everyone unconditionally instead of acting as a blocklist. --- .changeset/resolver-auth-field.md | 2 +- packages/sdk/docs/services/resolver.md | 17 ++-- .../src/cli/services/resolver/bundler.test.ts | 2 +- .../sdk/src/cli/shared/runtime-exprs.test.ts | 83 ++++++++++++++----- packages/sdk/src/cli/shared/runtime-exprs.ts | 42 ++++++++-- .../src/configure/services/resolver/index.ts | 6 +- .../configure/services/resolver/permission.ts | 33 ++++++-- .../services/resolver/resolver.test.ts | 9 +- .../configure/services/resolver/resolver.ts | 6 +- .../sdk/src/parser/service/resolver/schema.ts | 4 +- packages/sdk/src/types/resolver.generated.ts | 26 +++++- 11 files changed, 171 insertions(+), 59 deletions(-) diff --git a/.changeset/resolver-auth-field.md b/.changeset/resolver-auth-field.md index 05870574f7..5acd91ea2c 100644 --- a/.changeset/resolver-auth-field.md +++ b/.changeset/resolver-auth-field.md @@ -2,4 +2,4 @@ "@tailor-platform/sdk": minor --- -Add `auth` field to `createResolver` for declaring a resolver's access requirement, using the same `conditions`/`permit` notation as TailorDB's `.permission()` (restricted to `user` operands, e.g. `auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }`). Rejects non-matching callers before `body` runs. `auth: "public"` explicitly documents that anonymous callers are allowed. Omitting `auth` keeps prior behavior unchanged. +Add `auth` field to `createResolver` for declaring a resolver's access requirement, using the same `conditions`/`permit` policy notation as TailorDB's `.permission()` (restricted to `user` operands, e.g. `auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }]`). Rejects non-matching callers before `body` runs. Multiple policies combine like an allow-list with an explicit-deny override. `auth: "public"` explicitly documents that anonymous callers are allowed. Omitting `auth` keeps prior behavior unchanged. diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index c091bc30e2..ef7c2a8e43 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -362,7 +362,7 @@ import { createResolver, t } from "@tailor-platform/sdk"; export default createResolver({ name: "getMyOrders", operation: "query", - auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], output: t.object({ count: t.int() }), body: async (context) => { // context.user is guaranteed to be an authenticated caller here @@ -371,15 +371,22 @@ export default createResolver({ }); ``` -`auth` uses the same `conditions`/`permit` notation as TailorDB's `.permission()`, restricted to `user` operands (a resolver has no associated record to compare against) with equality (`=`/`!=`) comparisons: +`auth` uses the same `conditions`/`permit` notation as TailorDB's `.permission()` — an array of policies, restricted to `user` operands (a resolver has no associated record to compare against) with equality (`=`/`!=`) comparisons: - `{ user: "_loggedIn" }` — whether the caller is authenticated - `{ user: "id" }` — the caller's user ID -- `{ user: "someAttribute" }` — any attribute enabled in `auth.userProfile.attributes` +- `{ user: "someAttribute" }` — any attribute enabled in `auth.userProfile.attributes` (or `auth.machineUserAttributes` for machine users) -Multiple conditions in the same `conditions` array are combined with AND. `permit` is required — `permit: true` denies callers that _don't_ match, `permit: false` denies callers that _do_ match. +Multiple conditions within the same policy's `conditions` array are combined with AND. `permit` is required. A `permit: false` policy always denies matching callers. With no `permit: true` policy, `auth` is a pure blocklist (everyone else is allowed); with at least one `permit: true` policy, it becomes an allow-list (denied by default, granted only by a matching `permit: true` policy). This lets you express different eligibility paths, e.g. allowing machine-user callers unconditionally while gating regular users behind a role check: -Besides a conditions object, `auth` also accepts: +```typescript +auth: [ + { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, +], +``` + +Besides a policy array, `auth` also accepts: - `"public"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `auth`, but records the decision so it isn't mistaken for an oversight. - Omitted (default) — unchanged: anonymous callers can still reach the resolver. diff --git a/packages/sdk/src/cli/services/resolver/bundler.test.ts b/packages/sdk/src/cli/services/resolver/bundler.test.ts index 5017655d08..5700f73cec 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.test.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.test.ts @@ -51,7 +51,7 @@ describe("bundleResolvers", () => { `export default {\n` + ` operation: "query",\n` + ` name: "protected",\n` + - ` auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true },\n` + + ` auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }],\n` + ` body: async () => 1,\n` + ` output: { type: "integer", metadata: {}, fields: {} },\n` + `};\n`, diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index 9c8b3cc4b1..d56e5b6cc7 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -130,50 +130,54 @@ describe("buildResolverAuthGuardExpr", () => { }); test("_loggedIn permit:true allows an authenticated user", () => { - const auth = { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true } as const; + const auth = [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }] as const; expect(() => runGuard(auth, { type: "user" })).not.toThrow(); }); test("_loggedIn permit:true rejects an anonymous user", () => { - const auth = { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true } as const; + const auth = [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }] as const; expect(() => runGuard(auth, { type: "" })).toThrow(TailorErrorMessage); }); test("permit:false denies matching callers instead of allowing them", () => { - const auth = { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: false } as const; + const auth = [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: false }] as const; expect(() => runGuard(auth, { type: "user" })).toThrow(TailorErrorMessage); expect(() => runGuard(auth, { type: "" })).not.toThrow(); }); test("supports the != operator", () => { - const auth = { conditions: [[{ user: "role" }, "!=", "BANNED"]], permit: true } as const; + const auth = [{ conditions: [[{ user: "role" }, "!=", "BANNED"]], permit: true }] as const; expect(() => runGuard(auth, { attributes: { role: "MEMBER" } })).not.toThrow(); expect(() => runGuard(auth, { attributes: { role: "BANNED" } })).toThrow(TailorErrorMessage); }); test("supports the id operand", () => { - const auth = { - conditions: [[{ user: "id" }, "=", "11111111-1111-1111-1111-111111111111"]], - permit: true, - } as const; + const auth = [ + { + conditions: [[{ user: "id" }, "=", "11111111-1111-1111-1111-111111111111"]], + permit: true, + }, + ] as const; expect(() => runGuard(auth, { id: "11111111-1111-1111-1111-111111111111" })).not.toThrow(); expect(() => runGuard(auth, { id: "other" })).toThrow(TailorErrorMessage); }); test("supports arbitrary user attribute operands", () => { - const auth = { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true } as const; + const auth = [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }] as const; expect(() => runGuard(auth, { attributes: { role: "ADMIN" } })).not.toThrow(); expect(() => runGuard(auth, { attributes: { role: "MEMBER" } })).toThrow(TailorErrorMessage); }); - test("ANDs multiple conditions", () => { - const auth = { - conditions: [ - [{ user: "_loggedIn" }, "=", true], - [{ user: "role" }, "=", "ADMIN"], - ], - permit: true, - } as const; + test("ANDs multiple conditions within a policy", () => { + const auth = [ + { + conditions: [ + [{ user: "_loggedIn" }, "=", true], + [{ user: "role" }, "=", "ADMIN"], + ], + permit: true, + }, + ] as const; expect(() => runGuard(auth, { type: "user", attributes: { role: "ADMIN" } })).not.toThrow(); expect(() => runGuard(auth, { type: "user", attributes: { role: "MEMBER" } })).toThrow( TailorErrorMessage, @@ -183,12 +187,47 @@ describe("buildResolverAuthGuardExpr", () => { ); }); + test("ORs multiple allow policies", () => { + // Allow machine-user callers unconditionally, or regular users with role ADMIN + const auth = [ + { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, + ] as const; + expect(() => + runGuard(auth, { attributes: { isServiceAccount: true, role: "MEMBER" } }), + ).not.toThrow(); + expect(() => + runGuard(auth, { attributes: { isServiceAccount: false, role: "ADMIN" } }), + ).not.toThrow(); + expect(() => + runGuard(auth, { attributes: { isServiceAccount: false, role: "MEMBER" } }), + ).toThrow(TailorErrorMessage); + }); + + test("a deny policy overrides a matching allow policy", () => { + const auth = [ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + { conditions: [[{ user: "role" }, "=", "BANNED"]], permit: false }, + ] as const; + expect(() => runGuard(auth, { type: "user", attributes: { role: "MEMBER" } })).not.toThrow(); + expect(() => runGuard(auth, { type: "user", attributes: { role: "BANNED" } })).toThrow( + TailorErrorMessage, + ); + }); + + test("denies by default when no allow policy matches", () => { + const auth = [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }] as const; + expect(() => runGuard(auth, { attributes: { role: "GUEST" } })).toThrow(TailorErrorMessage); + }); + test("includes description in the thrown message when present", () => { - const auth = { - conditions: [[{ user: "_loggedIn" }, "=", true]], - permit: true, - description: "must be logged in", - } as const; + const auth = [ + { + conditions: [[{ user: "_loggedIn" }, "=", true]], + permit: true, + description: "must be logged in", + }, + ] as const; expect(() => runGuard(auth, { type: "" })).toThrow(/must be logged in/); }); }); diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index 36726d2764..d763522dc8 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -104,7 +104,8 @@ export function buildResolverOperationHookExpr( return `({ ...context.pipeline, input: context.args, user: ${tailorUserMap}, env: ${JSON.stringify(env)} });`; } -type ResolverAuthPolicy = Extract, { conditions: unknown }>; +type ResolverAuthPolicies = Extract, readonly unknown[]>; +type ResolverAuthPolicy = ResolverAuthPolicies[number]; type ResolverPermissionOperand = string | boolean | { user: string }; type ResolverPermissionCondition = readonly [ ResolverPermissionOperand, @@ -137,12 +138,22 @@ function resolverPermissionConditionExpr(condition: ResolverPermissionCondition) return `(${resolverPermissionOperandExpr(left)} ${jsOperator} ${resolverPermissionOperandExpr(right)})`; } +function resolverPermissionPolicyExpr(policy: ResolverAuthPolicy): string { + const conditions = isSingleResolverCondition(policy.conditions) + ? [policy.conditions] + : policy.conditions; + return conditions.map(resolverPermissionConditionExpr).join(" && "); +} + /** * Build the auth guard statement injected at resolver entry. * * Rejects the call with `TailorErrorMessage` when the caller doesn't match - * `auth`'s conditions, evaluated against `context.user` — the original caller, - * unaffected by `authInvoker`. + * `auth`, evaluated against `context.user` — the original caller, unaffected + * by `authInvoker`. A `permit: false` policy always denies matching callers. + * With no `permit: true` policy, `auth` is a pure blocklist (everyone else is + * allowed); with at least one, it's an allow-list (deny by default, granted + * only by a matching `permit: true` policy). * @param auth - The resolver's `auth` config * @returns A JS `if (...) throw ...;` statement, or `undefined` when `auth` is omitted or `"public"` */ @@ -150,11 +161,24 @@ export function buildResolverAuthGuardExpr(auth: Resolver["auth"]): string | und if (!auth || auth === "public") { return undefined; } - const conditions = isSingleResolverCondition(auth.conditions) - ? [auth.conditions] - : auth.conditions; - const combined = conditions.map(resolverPermissionConditionExpr).join(" && "); - const denyExpr = auth.permit === false ? `(${combined})` : `!(${combined})`; - const message = auth.description ? `access denied: ${auth.description}` : "access denied"; + const denyPolicies = auth.filter((policy) => policy.permit === false); + const allowPolicies = auth.filter((policy) => policy.permit !== false); + + const deniedExpr = + denyPolicies.length > 0 + ? denyPolicies.map((policy) => `(${resolverPermissionPolicyExpr(policy)})`).join(" || ") + : "false"; + + // With no allow policies, `auth` is a pure blocklist: deny only callers matching + // a deny policy, allow everyone else. With at least one allow policy, `auth` is + // an allow-list: deny anyone that doesn't match an allow policy (in addition to + // the deny-policy override above). + const denyExpr = + allowPolicies.length > 0 + ? `(${deniedExpr}) || !(${allowPolicies.map((policy) => `(${resolverPermissionPolicyExpr(policy)})`).join(" || ")})` + : deniedExpr; + const descriptions = auth.map((policy) => policy.description).filter((d) => !!d); + const message = + descriptions.length > 0 ? `access denied: ${descriptions.join("; ")}` : "access denied"; return `if (${denyExpr}) { throw new TailorErrorMessage(${JSON.stringify(message)}); }`; } diff --git a/packages/sdk/src/configure/services/resolver/index.ts b/packages/sdk/src/configure/services/resolver/index.ts index 924478ddc0..3a87f99be5 100644 --- a/packages/sdk/src/configure/services/resolver/index.ts +++ b/packages/sdk/src/configure/services/resolver/index.ts @@ -2,7 +2,11 @@ export { createResolver } from "./resolver"; export type { Resolver } from "#/types/resolver.generated"; export type { QueryType } from "./types"; -export type { ResolverPermission, ResolverPermissionCondition } from "./permission"; +export type { + ResolverPermission, + ResolverPermissionCondition, + ResolverPermissionPolicy, +} from "./permission"; export type { ResolverServiceConfig, ResolverExternalConfig, diff --git a/packages/sdk/src/configure/services/resolver/permission.ts b/packages/sdk/src/configure/services/resolver/permission.ts index 91a0371d72..93e6c0b2c4 100644 --- a/packages/sdk/src/configure/services/resolver/permission.ts +++ b/packages/sdk/src/configure/services/resolver/permission.ts @@ -42,17 +42,34 @@ export type ResolverPermissionCondition; /** - * Access requirement for a resolver, evaluated against the original caller - * (`context.user`) before `body` runs — unaffected by `authInvoker`. - * @example - * const auth: ResolverPermission = { - * conditions: [[{ user: "_loggedIn" }, "=", true]], - * permit: true, - * }; + * A single access policy, in the same style as TailorDB's `.permission()` + * policies. */ -export type ResolverPermission = { +export type ResolverPermissionPolicy = { conditions: ResolverPermissionCondition | readonly ResolverPermissionCondition[]; /** Whether matching callers are granted (`true`) or denied (`false`) access. */ permit: boolean; description?: string; }; + +/** + * Access requirement for a resolver, evaluated against the original caller + * (`context.user`) before `body` runs — unaffected by `authInvoker`. + * + * A `permit: false` policy always denies matching callers. With no + * `permit: true` policy, this is a pure blocklist (everyone else is allowed); + * with at least one, it's an allow-list (deny by default, granted only by a + * matching `permit: true` policy). + * @example + * const auth: ResolverPermission = [ + * { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + * ]; + * @example + * // Allow machine-user callers unconditionally, gate regular users behind a role + * const auth: ResolverPermission = [ + * { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + * { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, + * ]; + */ +export type ResolverPermission = + readonly ResolverPermissionPolicy[]; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index ba2b448fe7..2bc1f2abee 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -473,13 +473,12 @@ describe("createResolver", () => { operation: "query", output: outputType, body: () => ({ result: "ok" }), - auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], }); - expect(resolver.auth).toEqual({ - conditions: [[{ user: "_loggedIn" }, "=", true]], - permit: true, - }); + expect(resolver.auth).toEqual([ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ]); }); test("creates resolver with auth: public", () => { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 788785159e..bffb4521b7 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -63,8 +63,8 @@ type ResolverReturn< * `auth` declares the resolver's access requirement, checked against `context.user` (the * original caller, unaffected by `authInvoker`) before `body` runs. Omitted (default): * unchanged, anonymous callers can reach the resolver. `"public"`: explicitly documents that - * anonymous callers are allowed. A `{ conditions, permit }` policy (in the same style as - * TailorDB's `.permission()`) rejects callers that don't match. + * anonymous callers are allowed. An array of `{ conditions, permit }` policies (in the same + * style as TailorDB's `.permission()`) rejects callers that don't match any allow policy. * @template Input * @template Output * @param config - Resolver configuration @@ -75,7 +75,7 @@ type ResolverReturn< * export default createResolver({ * name: "getUser", * operation: "query", - * auth: { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + * auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], * input: { * id: t.string(), * }, diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index ea88778f50..9e002266b3 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -23,7 +23,7 @@ const ResolverPermissionConditionSchema = z ]) .readonly(); -const ResolverPermissionSchema = z.object({ +const ResolverPermissionPolicySchema = z.object({ conditions: z.union([ ResolverPermissionConditionSchema, z.array(ResolverPermissionConditionSchema).readonly(), @@ -33,7 +33,7 @@ const ResolverPermissionSchema = z.object({ }); export const ResolverAuthSchema = z - .union([ResolverPermissionSchema, z.literal("public")]) + .union([z.array(ResolverPermissionPolicySchema).readonly(), z.literal("public")]) .describe( "Access requirement for this resolver, evaluated against the original caller " + '(unaffected by `authInvoker`) before `body` runs. "public" documents that anonymous ' + diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index 36252bf7be..7ce0991332 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -1,5 +1,27 @@ // Generated by zinfer - Do not edit manually +/** + * Access requirement for this resolver, evaluated against the original caller (unaffected by `authInvoker`) before `body` runs. "public" documents that anonymous callers are allowed. Omitted (default): unchanged, anonymous callers can reach the resolver + */ +export type ResolverAuth = + | "public" + | readonly { + conditions: + | readonly [ + string | boolean | { user: string }, + "=" | "!=", + string | boolean | { user: string }, + ] + | readonly (readonly [ + string | boolean | { user: string }, + "=" | "!=", + string | boolean | { user: string }, + ])[]; + permit: boolean; + description?: string | undefined; + }[]; +export type ResolverAuthInput = ResolverAuth; + export type Resolver = { /** GraphQL operation type (query or mutation) */ operation: "query" | "mutation"; @@ -65,7 +87,7 @@ export type Resolver = { | undefined; auth?: | "public" - | { + | readonly { conditions: | readonly [ ( @@ -104,7 +126,7 @@ export type Resolver = { permit: boolean; /** Resolver description */ description?: string | undefined; - } + }[] | undefined; }; export type ResolverInput = Resolver; From 5a4937f63284c627701e63db65ee8cebd4a8c3c5 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 10 Jul 2026 14:42:16 +0900 Subject: [PATCH 04/11] fix(resolver): reject empty auth conditions/policies Address Copilot review feedback on PR #1718 (this repo): - Require at least one condition in a policy's `conditions` array, and at least one policy in `auth`, via zod .min(1) with descriptive error messages (an empty array previously produced a silent no-op guard instead of a validation error) - Add a defensive check in the bundle-time JS compiler that throws a clear error instead of emitting invalid code if either array is somehow empty at that point (schema validation should already prevent this, but the compiler should not assume it) - Fix outdated JSDoc on `auth` that described only allow-list semantics, omitting the blocklist mode when no `permit: true` policy is present - Add parser/service/resolver/schema.test.ts covering the new validation --- .../sdk/src/cli/shared/runtime-exprs.test.ts | 9 +++ packages/sdk/src/cli/shared/runtime-exprs.ts | 6 ++ .../configure/services/resolver/resolver.ts | 4 +- .../parser/service/resolver/schema.test.ts | 55 +++++++++++++++++++ .../sdk/src/parser/service/resolver/schema.ts | 13 ++++- 5 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/src/parser/service/resolver/schema.test.ts diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index d56e5b6cc7..7f4ed1a332 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -230,4 +230,13 @@ describe("buildResolverAuthGuardExpr", () => { ] as const; expect(() => runGuard(auth, { type: "" })).toThrow(/must be logged in/); }); + + test("throws at bundle time on an empty auth array (schema should reject this, but guard defensively too)", () => { + expect(() => buildResolverAuthGuardExpr([])).toThrow(/at least one policy/); + }); + + test("throws at bundle time on a policy with an empty conditions array (schema should reject this, but guard defensively too)", () => { + const auth = [{ conditions: [], permit: true }] as const; + expect(() => buildResolverAuthGuardExpr(auth)).toThrow(/at least one condition/); + }); }); diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index d763522dc8..845fcee4b9 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -142,6 +142,9 @@ function resolverPermissionPolicyExpr(policy: ResolverAuthPolicy): string { const conditions = isSingleResolverCondition(policy.conditions) ? [policy.conditions] : policy.conditions; + if (conditions.length === 0) { + throw new Error("Resolver auth policy must have at least one condition, got an empty array."); + } return conditions.map(resolverPermissionConditionExpr).join(" && "); } @@ -161,6 +164,9 @@ export function buildResolverAuthGuardExpr(auth: Resolver["auth"]): string | und if (!auth || auth === "public") { return undefined; } + if (auth.length === 0) { + throw new Error("Resolver auth must have at least one policy, got an empty array."); + } const denyPolicies = auth.filter((policy) => policy.permit === false); const allowPolicies = auth.filter((policy) => policy.permit !== false); diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index bffb4521b7..02f0810ebf 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -64,7 +64,9 @@ type ResolverReturn< * original caller, unaffected by `authInvoker`) before `body` runs. Omitted (default): * unchanged, anonymous callers can reach the resolver. `"public"`: explicitly documents that * anonymous callers are allowed. An array of `{ conditions, permit }` policies (in the same - * style as TailorDB's `.permission()`) rejects callers that don't match any allow policy. + * style as TailorDB's `.permission()`) rejects non-matching callers: with no `permit: true` + * policy it's a blocklist (only `permit: false` matches are denied), with at least one it's + * an allow-list (denied unless a `permit: true` policy matches). * @template Input * @template Output * @param config - Resolver configuration diff --git a/packages/sdk/src/parser/service/resolver/schema.test.ts b/packages/sdk/src/parser/service/resolver/schema.test.ts new file mode 100644 index 0000000000..e6e9d5bf89 --- /dev/null +++ b/packages/sdk/src/parser/service/resolver/schema.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "vitest"; +import { ResolverAuthSchema } from "./schema"; + +describe("ResolverAuthSchema", () => { + test("accepts a single policy with one condition", () => { + expect(() => + ResolverAuthSchema.parse([ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ]), + ).not.toThrow(); + }); + + test("accepts a single policy with multiple conditions", () => { + expect(() => + ResolverAuthSchema.parse([ + { + conditions: [ + [{ user: "_loggedIn" }, "=", true], + [{ user: "role" }, "=", "ADMIN"], + ], + permit: true, + }, + ]), + ).not.toThrow(); + }); + + test("accepts multiple policies", () => { + expect(() => + ResolverAuthSchema.parse([ + { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, + { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, + ]), + ).not.toThrow(); + }); + + test('accepts "public"', () => { + expect(() => ResolverAuthSchema.parse("public")).not.toThrow(); + }); + + test("rejects an empty policy array", () => { + expect(() => ResolverAuthSchema.parse([])).toThrow("auth must have at least one policy"); + }); + + test("rejects a policy with an empty conditions array", () => { + expect(() => ResolverAuthSchema.parse([{ conditions: [], permit: true }])).toThrow( + "must have at least one condition", + ); + }); + + test("rejects a policy missing permit", () => { + expect(() => + ResolverAuthSchema.parse([{ conditions: [[{ user: "_loggedIn" }, "=", true]] }]), + ).toThrow("permit"); + }); +}); diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index 9e002266b3..8301bb5ff8 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -26,14 +26,23 @@ const ResolverPermissionConditionSchema = z const ResolverPermissionPolicySchema = z.object({ conditions: z.union([ ResolverPermissionConditionSchema, - z.array(ResolverPermissionConditionSchema).readonly(), + z + .array(ResolverPermissionConditionSchema) + .min(1, "Resolver auth policy must have at least one condition") + .readonly(), ]), permit: z.boolean(), description: z.string().optional(), }); export const ResolverAuthSchema = z - .union([z.array(ResolverPermissionPolicySchema).readonly(), z.literal("public")]) + .union([ + z + .array(ResolverPermissionPolicySchema) + .min(1, "Resolver auth must have at least one policy") + .readonly(), + z.literal("public"), + ]) .describe( "Access requirement for this resolver, evaluated against the original caller " + '(unaffected by `authInvoker`) before `body` runs. "public" documents that anonymous ' + From d688c303be1b6693a36b50a03d52c670c05d035a Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 10 Jul 2026 14:56:23 +0900 Subject: [PATCH 05/11] refactor(resolver): rename auth field to permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the field to match TailorDB's `.permission()`/`.gqlPermission()` and IdP's `permission` field naming convention, rather than introducing a new "auth" vocabulary. No behavior change — same `{ conditions, permit }` policy shape, still evaluated against `context.user` before `body` runs. --- .changeset/resolver-auth-field.md | 5 - .changeset/resolver-permission-field.md | 5 + packages/sdk/docs/services/resolver.md | 16 +-- .../src/cli/services/resolver/bundler.test.ts | 24 ++-- .../sdk/src/cli/services/resolver/bundler.ts | 10 +- .../sdk/src/cli/shared/runtime-exprs.test.ts | 109 +++++++++++------- packages/sdk/src/cli/shared/runtime-exprs.ts | 48 ++++---- .../configure/services/resolver/permission.ts | 4 +- .../services/resolver/resolver.test.ts | 18 +-- .../configure/services/resolver/resolver.ts | 12 +- .../parser/service/resolver/schema.test.ts | 20 ++-- .../sdk/src/parser/service/resolver/schema.ts | 8 +- packages/sdk/src/types/resolver.generated.ts | 6 +- 13 files changed, 157 insertions(+), 128 deletions(-) delete mode 100644 .changeset/resolver-auth-field.md create mode 100644 .changeset/resolver-permission-field.md diff --git a/.changeset/resolver-auth-field.md b/.changeset/resolver-auth-field.md deleted file mode 100644 index 5acd91ea2c..0000000000 --- a/.changeset/resolver-auth-field.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tailor-platform/sdk": minor ---- - -Add `auth` field to `createResolver` for declaring a resolver's access requirement, using the same `conditions`/`permit` policy notation as TailorDB's `.permission()` (restricted to `user` operands, e.g. `auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }]`). Rejects non-matching callers before `body` runs. Multiple policies combine like an allow-list with an explicit-deny override. `auth: "public"` explicitly documents that anonymous callers are allowed. Omitting `auth` keeps prior behavior unchanged. diff --git a/.changeset/resolver-permission-field.md b/.changeset/resolver-permission-field.md new file mode 100644 index 0000000000..acb308acc9 --- /dev/null +++ b/.changeset/resolver-permission-field.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add `permission` field to `createResolver` for declaring a resolver's access requirement, using the same `conditions`/`permit` policy notation as TailorDB's `.permission()` (restricted to `user` operands, e.g. `permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }]`). Rejects non-matching callers before `body` runs. Multiple policies combine like an allow-list with an explicit-deny override. `permission: "public"` explicitly documents that anonymous callers are allowed. Omitting `permission` keeps prior behavior unchanged. diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index ef7c2a8e43..ab97d3e22b 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -352,9 +352,9 @@ createResolver({ ## Authentication -### Access Requirement (`auth`) +### Access Requirement (`permission`) -By default, a resolver with no in-body check is reachable by an anonymous (unauthenticated) caller. Set `auth` to reject callers that don't match a condition, evaluated before `body` runs: +By default, a resolver with no in-body check is reachable by an anonymous (unauthenticated) caller. Set `permission` to reject callers that don't match a condition, evaluated before `body` runs: ```typescript import { createResolver, t } from "@tailor-platform/sdk"; @@ -362,7 +362,7 @@ import { createResolver, t } from "@tailor-platform/sdk"; export default createResolver({ name: "getMyOrders", operation: "query", - auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], + permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], output: t.object({ count: t.int() }), body: async (context) => { // context.user is guaranteed to be an authenticated caller here @@ -371,24 +371,24 @@ export default createResolver({ }); ``` -`auth` uses the same `conditions`/`permit` notation as TailorDB's `.permission()` — an array of policies, restricted to `user` operands (a resolver has no associated record to compare against) with equality (`=`/`!=`) comparisons: +`permission` uses the same `conditions`/`permit` notation as TailorDB's `.permission()` — an array of policies, restricted to `user` operands (a resolver has no associated record to compare against) with equality (`=`/`!=`) comparisons: - `{ user: "_loggedIn" }` — whether the caller is authenticated - `{ user: "id" }` — the caller's user ID - `{ user: "someAttribute" }` — any attribute enabled in `auth.userProfile.attributes` (or `auth.machineUserAttributes` for machine users) -Multiple conditions within the same policy's `conditions` array are combined with AND. `permit` is required. A `permit: false` policy always denies matching callers. With no `permit: true` policy, `auth` is a pure blocklist (everyone else is allowed); with at least one `permit: true` policy, it becomes an allow-list (denied by default, granted only by a matching `permit: true` policy). This lets you express different eligibility paths, e.g. allowing machine-user callers unconditionally while gating regular users behind a role check: +Multiple conditions within the same policy's `conditions` array are combined with AND. `permit` is required. A `permit: false` policy always denies matching callers. With no `permit: true` policy, `permission` is a pure blocklist (everyone else is allowed); with at least one `permit: true` policy, it becomes an allow-list (denied by default, granted only by a matching `permit: true` policy). This lets you express different eligibility paths, e.g. allowing machine-user callers unconditionally while gating regular users behind a role check: ```typescript -auth: [ +permission: [ { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, ], ``` -Besides a policy array, `auth` also accepts: +Besides a policy array, `permission` also accepts: -- `"public"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `auth`, but records the decision so it isn't mistaken for an oversight. +- `"public"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `permission`, but records the decision so it isn't mistaken for an oversight. - Omitted (default) — unchanged: anonymous callers can still reach the resolver. This check is based on `context.user`, the original caller, so it still applies even when `authInvoker` swaps in a machine user for database access. diff --git a/packages/sdk/src/cli/services/resolver/bundler.test.ts b/packages/sdk/src/cli/services/resolver/bundler.test.ts index 5700f73cec..f189489ea5 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.test.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.test.ts @@ -42,23 +42,23 @@ describe("bundleResolvers", () => { ).resolves.toEqual(new Map()); }); - test("injects the auth guard into the entry file", async () => { - using tmp = tempCwd("sdk-bundler-auth-"); - const resolverDir = path.join(tmp.dir, "src/backend/authcheck/resolver"); + test("injects the permission guard into the entry file", async () => { + using tmp = tempCwd("sdk-bundler-permission-"); + const resolverDir = path.join(tmp.dir, "src/backend/permissioncheck/resolver"); fs.mkdirSync(resolverDir, { recursive: true }); fs.writeFileSync( path.join(resolverDir, "protected.ts"), `export default {\n` + ` operation: "query",\n` + ` name: "protected",\n` + - ` auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }],\n` + + ` permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }],\n` + ` body: async () => 1,\n` + ` output: { type: "integer", metadata: {}, fields: {} },\n` + `};\n`, ); - await bundleResolvers("authcheck", { - files: ["./src/backend/authcheck/resolver/*.ts"], + await bundleResolvers("permissioncheck", { + files: ["./src/backend/permissioncheck/resolver/*.ts"], }); const entryContent = fs.readFileSync( @@ -71,23 +71,23 @@ describe("bundleResolvers", () => { expect(entryContent).toContain("access denied"); }); - test("does not inject a guard when auth is omitted or public", async () => { - using tmp = tempCwd("sdk-bundler-noauth-"); - const resolverDir = path.join(tmp.dir, "src/backend/noauth/resolver"); + test("does not inject a guard when permission is omitted or public", async () => { + using tmp = tempCwd("sdk-bundler-nopermission-"); + const resolverDir = path.join(tmp.dir, "src/backend/nopermission/resolver"); fs.mkdirSync(resolverDir, { recursive: true }); fs.writeFileSync( path.join(resolverDir, "open.ts"), `export default {\n` + ` operation: "query",\n` + ` name: "open",\n` + - ` auth: "public",\n` + + ` permission: "public",\n` + ` body: async () => 1,\n` + ` output: { type: "integer", metadata: {}, fields: {} },\n` + `};\n`, ); - await bundleResolvers("noauth", { - files: ["./src/backend/noauth/resolver/*.ts"], + await bundleResolvers("nopermission", { + files: ["./src/backend/nopermission/resolver/*.ts"], }); const entryContent = fs.readFileSync( diff --git a/packages/sdk/src/cli/services/resolver/bundler.ts b/packages/sdk/src/cli/services/resolver/bundler.ts index a12c344e68..abac5bc035 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.ts @@ -11,7 +11,7 @@ import { getDistDir } from "#/cli/shared/dist-dir"; import { composeFunctionTreeshakeOptions } from "#/cli/shared/function-treeshake"; import { logger, styles } from "#/cli/shared/logger"; import { platformBundleDefinePlugin } from "#/cli/shared/platform-bundle-plugin"; -import { buildResolverAuthGuardExpr, INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; +import { buildResolverPermissionGuardExpr, INVOKER_EXPR } from "#/cli/shared/runtime-exprs"; import { createTriggerTransformPlugin, serializeTriggerContext, @@ -25,7 +25,7 @@ import type { Resolver } from "#/types/resolver.generated"; interface ResolverInfo { name: string; sourceFile: string; - auth: Resolver["auth"]; + permission: Resolver["permission"]; } /** @@ -76,7 +76,7 @@ export async function bundleResolvers( resolvers.push({ name: resolver.name, sourceFile: file, - auth: resolver.auth, + permission: resolver.permission, }); } @@ -151,7 +151,7 @@ async function bundleSingleResolver( // Step 1: Create entry file that imports from the original source const entryPath = path.join(outputDir, `${resolver.name}.entry.js`); const absoluteSourcePath = path.resolve(resolver.sourceFile); - const authGuardExpr = buildResolverAuthGuardExpr(resolver.auth); + const permissionGuardExpr = buildResolverPermissionGuardExpr(resolver.permission); const entryContent = ml /* js */ ` import _internalResolver from "${absoluteSourcePath}"; @@ -159,7 +159,7 @@ async function bundleSingleResolver( const $tailor_resolver_body = async (context) => { const invoker = ${INVOKER_EXPR}; - ${authGuardExpr ?? ""} + ${permissionGuardExpr ?? ""} if (_internalResolver.input) { const result = t.object(_internalResolver.input).parse({ value: context.input, diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index 7f4ed1a332..478d5918e1 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect } from "vitest"; import { buildExecutorArgsExpr, - buildResolverAuthGuardExpr, buildResolverOperationHookExpr, + buildResolverPermissionGuardExpr, } from "./runtime-exprs"; describe("buildExecutorArgsExpr", () => { @@ -108,11 +108,14 @@ describe("buildResolverOperationHookExpr", () => { }); }); -describe("buildResolverAuthGuardExpr", () => { +describe("buildResolverPermissionGuardExpr", () => { class TailorErrorMessage extends Error {} - function runGuard(auth: Parameters[0], user: unknown): void { - const guard = buildResolverAuthGuardExpr(auth); + function runGuard( + permission: Parameters[0], + user: unknown, + ): void { + const guard = buildResolverPermissionGuardExpr(permission); if (!guard) { return; } @@ -121,55 +124,69 @@ describe("buildResolverAuthGuardExpr", () => { fn({ user }, TailorErrorMessage); } - test("returns undefined when auth is omitted", () => { - expect(buildResolverAuthGuardExpr(undefined)).toBeUndefined(); + test("returns undefined when permission is omitted", () => { + expect(buildResolverPermissionGuardExpr(undefined)).toBeUndefined(); }); - test("returns undefined when auth is public", () => { - expect(buildResolverAuthGuardExpr("public")).toBeUndefined(); + test("returns undefined when permission is public", () => { + expect(buildResolverPermissionGuardExpr("public")).toBeUndefined(); }); test("_loggedIn permit:true allows an authenticated user", () => { - const auth = [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }] as const; - expect(() => runGuard(auth, { type: "user" })).not.toThrow(); + const permission = [ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ] as const; + expect(() => runGuard(permission, { type: "user" })).not.toThrow(); }); test("_loggedIn permit:true rejects an anonymous user", () => { - const auth = [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }] as const; - expect(() => runGuard(auth, { type: "" })).toThrow(TailorErrorMessage); + const permission = [ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, + ] as const; + expect(() => runGuard(permission, { type: "" })).toThrow(TailorErrorMessage); }); test("permit:false denies matching callers instead of allowing them", () => { - const auth = [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: false }] as const; - expect(() => runGuard(auth, { type: "user" })).toThrow(TailorErrorMessage); - expect(() => runGuard(auth, { type: "" })).not.toThrow(); + const permission = [ + { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: false }, + ] as const; + expect(() => runGuard(permission, { type: "user" })).toThrow(TailorErrorMessage); + expect(() => runGuard(permission, { type: "" })).not.toThrow(); }); test("supports the != operator", () => { - const auth = [{ conditions: [[{ user: "role" }, "!=", "BANNED"]], permit: true }] as const; - expect(() => runGuard(auth, { attributes: { role: "MEMBER" } })).not.toThrow(); - expect(() => runGuard(auth, { attributes: { role: "BANNED" } })).toThrow(TailorErrorMessage); + const permission = [ + { conditions: [[{ user: "role" }, "!=", "BANNED"]], permit: true }, + ] as const; + expect(() => runGuard(permission, { attributes: { role: "MEMBER" } })).not.toThrow(); + expect(() => runGuard(permission, { attributes: { role: "BANNED" } })).toThrow( + TailorErrorMessage, + ); }); test("supports the id operand", () => { - const auth = [ + const permission = [ { conditions: [[{ user: "id" }, "=", "11111111-1111-1111-1111-111111111111"]], permit: true, }, ] as const; - expect(() => runGuard(auth, { id: "11111111-1111-1111-1111-111111111111" })).not.toThrow(); - expect(() => runGuard(auth, { id: "other" })).toThrow(TailorErrorMessage); + expect(() => + runGuard(permission, { id: "11111111-1111-1111-1111-111111111111" }), + ).not.toThrow(); + expect(() => runGuard(permission, { id: "other" })).toThrow(TailorErrorMessage); }); test("supports arbitrary user attribute operands", () => { - const auth = [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }] as const; - expect(() => runGuard(auth, { attributes: { role: "ADMIN" } })).not.toThrow(); - expect(() => runGuard(auth, { attributes: { role: "MEMBER" } })).toThrow(TailorErrorMessage); + const permission = [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }] as const; + expect(() => runGuard(permission, { attributes: { role: "ADMIN" } })).not.toThrow(); + expect(() => runGuard(permission, { attributes: { role: "MEMBER" } })).toThrow( + TailorErrorMessage, + ); }); test("ANDs multiple conditions within a policy", () => { - const auth = [ + const permission = [ { conditions: [ [{ user: "_loggedIn" }, "=", true], @@ -178,65 +195,71 @@ describe("buildResolverAuthGuardExpr", () => { permit: true, }, ] as const; - expect(() => runGuard(auth, { type: "user", attributes: { role: "ADMIN" } })).not.toThrow(); - expect(() => runGuard(auth, { type: "user", attributes: { role: "MEMBER" } })).toThrow( + expect(() => + runGuard(permission, { type: "user", attributes: { role: "ADMIN" } }), + ).not.toThrow(); + expect(() => runGuard(permission, { type: "user", attributes: { role: "MEMBER" } })).toThrow( TailorErrorMessage, ); - expect(() => runGuard(auth, { type: "", attributes: { role: "ADMIN" } })).toThrow( + expect(() => runGuard(permission, { type: "", attributes: { role: "ADMIN" } })).toThrow( TailorErrorMessage, ); }); test("ORs multiple allow policies", () => { // Allow machine-user callers unconditionally, or regular users with role ADMIN - const auth = [ + const permission = [ { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, ] as const; expect(() => - runGuard(auth, { attributes: { isServiceAccount: true, role: "MEMBER" } }), + runGuard(permission, { attributes: { isServiceAccount: true, role: "MEMBER" } }), ).not.toThrow(); expect(() => - runGuard(auth, { attributes: { isServiceAccount: false, role: "ADMIN" } }), + runGuard(permission, { attributes: { isServiceAccount: false, role: "ADMIN" } }), ).not.toThrow(); expect(() => - runGuard(auth, { attributes: { isServiceAccount: false, role: "MEMBER" } }), + runGuard(permission, { attributes: { isServiceAccount: false, role: "MEMBER" } }), ).toThrow(TailorErrorMessage); }); test("a deny policy overrides a matching allow policy", () => { - const auth = [ + const permission = [ { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, { conditions: [[{ user: "role" }, "=", "BANNED"]], permit: false }, ] as const; - expect(() => runGuard(auth, { type: "user", attributes: { role: "MEMBER" } })).not.toThrow(); - expect(() => runGuard(auth, { type: "user", attributes: { role: "BANNED" } })).toThrow( + expect(() => + runGuard(permission, { type: "user", attributes: { role: "MEMBER" } }), + ).not.toThrow(); + expect(() => runGuard(permission, { type: "user", attributes: { role: "BANNED" } })).toThrow( TailorErrorMessage, ); }); test("denies by default when no allow policy matches", () => { - const auth = [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }] as const; - expect(() => runGuard(auth, { attributes: { role: "GUEST" } })).toThrow(TailorErrorMessage); + const permission = [{ conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }] as const; + expect(() => runGuard(permission, { attributes: { role: "GUEST" } })).toThrow( + TailorErrorMessage, + ); }); test("includes description in the thrown message when present", () => { - const auth = [ + const permission = [ { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true, description: "must be logged in", }, ] as const; - expect(() => runGuard(auth, { type: "" })).toThrow(/must be logged in/); + expect(() => runGuard(permission, { type: "" })).toThrow(/must be logged in/); }); - test("throws at bundle time on an empty auth array (schema should reject this, but guard defensively too)", () => { - expect(() => buildResolverAuthGuardExpr([])).toThrow(/at least one policy/); + test("throws at bundle time on an empty permission array (schema should reject this, but guard defensively too)", () => { + expect(() => buildResolverPermissionGuardExpr([])).toThrow(/at least one policy/); }); test("throws at bundle time on a policy with an empty conditions array (schema should reject this, but guard defensively too)", () => { - const auth = [{ conditions: [], permit: true }] as const; - expect(() => buildResolverAuthGuardExpr(auth)).toThrow(/at least one condition/); + const permission = [{ conditions: [], permit: true }] as const; + expect(() => buildResolverPermissionGuardExpr(permission)).toThrow(/at least one condition/); }); }); diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index 845fcee4b9..a06c00f644 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -104,8 +104,8 @@ export function buildResolverOperationHookExpr( return `({ ...context.pipeline, input: context.args, user: ${tailorUserMap}, env: ${JSON.stringify(env)} });`; } -type ResolverAuthPolicies = Extract, readonly unknown[]>; -type ResolverAuthPolicy = ResolverAuthPolicies[number]; +type ResolverPermissionPolicies = Extract, readonly unknown[]>; +type ResolverPermissionPolicy = ResolverPermissionPolicies[number]; type ResolverPermissionOperand = string | boolean | { user: string }; type ResolverPermissionCondition = readonly [ ResolverPermissionOperand, @@ -114,7 +114,7 @@ type ResolverPermissionCondition = readonly [ ]; function isSingleResolverCondition( - conditions: ResolverAuthPolicy["conditions"], + conditions: ResolverPermissionPolicy["conditions"], ): conditions is ResolverPermissionCondition { return conditions.length === 3 && typeof conditions[1] === "string"; } @@ -138,52 +138,56 @@ function resolverPermissionConditionExpr(condition: ResolverPermissionCondition) return `(${resolverPermissionOperandExpr(left)} ${jsOperator} ${resolverPermissionOperandExpr(right)})`; } -function resolverPermissionPolicyExpr(policy: ResolverAuthPolicy): string { +function resolverPermissionPolicyExpr(policy: ResolverPermissionPolicy): string { const conditions = isSingleResolverCondition(policy.conditions) ? [policy.conditions] : policy.conditions; if (conditions.length === 0) { - throw new Error("Resolver auth policy must have at least one condition, got an empty array."); + throw new Error( + "Resolver permission policy must have at least one condition, got an empty array.", + ); } return conditions.map(resolverPermissionConditionExpr).join(" && "); } /** - * Build the auth guard statement injected at resolver entry. + * Build the permission guard statement injected at resolver entry. * * Rejects the call with `TailorErrorMessage` when the caller doesn't match - * `auth`, evaluated against `context.user` — the original caller, unaffected - * by `authInvoker`. A `permit: false` policy always denies matching callers. - * With no `permit: true` policy, `auth` is a pure blocklist (everyone else is - * allowed); with at least one, it's an allow-list (deny by default, granted - * only by a matching `permit: true` policy). - * @param auth - The resolver's `auth` config - * @returns A JS `if (...) throw ...;` statement, or `undefined` when `auth` is omitted or `"public"` + * `permission`, evaluated against `context.user` — the original caller, + * unaffected by `authInvoker`. A `permit: false` policy always denies matching + * callers. With no `permit: true` policy, `permission` is a pure blocklist + * (everyone else is allowed); with at least one, it's an allow-list (deny by + * default, granted only by a matching `permit: true` policy). + * @param permission - The resolver's `permission` config + * @returns A JS `if (...) throw ...;` statement, or `undefined` when `permission` is omitted or `"public"` */ -export function buildResolverAuthGuardExpr(auth: Resolver["auth"]): string | undefined { - if (!auth || auth === "public") { +export function buildResolverPermissionGuardExpr( + permission: Resolver["permission"], +): string | undefined { + if (!permission || permission === "public") { return undefined; } - if (auth.length === 0) { - throw new Error("Resolver auth must have at least one policy, got an empty array."); + if (permission.length === 0) { + throw new Error("Resolver permission must have at least one policy, got an empty array."); } - const denyPolicies = auth.filter((policy) => policy.permit === false); - const allowPolicies = auth.filter((policy) => policy.permit !== false); + const denyPolicies = permission.filter((policy) => policy.permit === false); + const allowPolicies = permission.filter((policy) => policy.permit !== false); const deniedExpr = denyPolicies.length > 0 ? denyPolicies.map((policy) => `(${resolverPermissionPolicyExpr(policy)})`).join(" || ") : "false"; - // With no allow policies, `auth` is a pure blocklist: deny only callers matching - // a deny policy, allow everyone else. With at least one allow policy, `auth` is + // With no allow policies, `permission` is a pure blocklist: deny only callers matching + // a deny policy, allow everyone else. With at least one allow policy, `permission` is // an allow-list: deny anyone that doesn't match an allow policy (in addition to // the deny-policy override above). const denyExpr = allowPolicies.length > 0 ? `(${deniedExpr}) || !(${allowPolicies.map((policy) => `(${resolverPermissionPolicyExpr(policy)})`).join(" || ")})` : deniedExpr; - const descriptions = auth.map((policy) => policy.description).filter((d) => !!d); + const descriptions = permission.map((policy) => policy.description).filter((d) => !!d); const message = descriptions.length > 0 ? `access denied: ${descriptions.join("; ")}` : "access denied"; return `if (${denyExpr}) { throw new TailorErrorMessage(${JSON.stringify(message)}); }`; diff --git a/packages/sdk/src/configure/services/resolver/permission.ts b/packages/sdk/src/configure/services/resolver/permission.ts index 93e6c0b2c4..9d7a3487aa 100644 --- a/packages/sdk/src/configure/services/resolver/permission.ts +++ b/packages/sdk/src/configure/services/resolver/permission.ts @@ -61,12 +61,12 @@ export type ResolverPermissionPolicy * with at least one, it's an allow-list (deny by default, granted only by a * matching `permit: true` policy). * @example - * const auth: ResolverPermission = [ + * const permission: ResolverPermission = [ * { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, * ]; * @example * // Allow machine-user callers unconditionally, gate regular users behind a role - * const auth: ResolverPermission = [ + * const permission: ResolverPermission = [ * { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, * { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, * ]; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 2bc1f2abee..6c52089bac 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -465,34 +465,34 @@ describe("createResolver", () => { expect(resolver.authInvoker).toEqual({ namespace: "my-auth", machineUserName: "batch-user" }); }); - test("creates resolver with loggedIn auth condition", () => { + test("creates resolver with loggedIn permission condition", () => { const outputType = t.object({ result: t.string() }); const resolver = createResolver({ - name: "withAuthLoggedIn", + name: "withPermissionLoggedIn", operation: "query", output: outputType, body: () => ({ result: "ok" }), - auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], + permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], }); - expect(resolver.auth).toEqual([ + expect(resolver.permission).toEqual([ { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, ]); }); - test("creates resolver with auth: public", () => { + test("creates resolver with permission: public", () => { const outputType = t.object({ result: t.string() }); const resolver = createResolver({ - name: "withAuthPublic", + name: "withPermissionPublic", operation: "query", output: outputType, body: () => ({ result: "ok" }), - auth: "public", + permission: "public", }); - expect(resolver.auth).toBe("public"); + expect(resolver.permission).toBe("public"); }); test("creates minimal resolver without optional fields", () => { @@ -510,7 +510,7 @@ describe("createResolver", () => { expect(resolver.output).toBe(outputType); expect(resolver.description).toBeUndefined(); expect(resolver.input).toBeUndefined(); - expect(resolver.auth).toBeUndefined(); + expect(resolver.permission).toBeUndefined(); }); test("accepts Record as output and converts to t.object()", () => { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 02f0810ebf..03deb7e033 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -36,13 +36,13 @@ type NormalizedOutput | undefined, Output extends TailorAnyField | Record, -> = Omit & +> = Omit & Readonly<{ input?: Input; output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; - auth?: ResolverPermission | "public"; + permission?: ResolverPermission | "public"; }>; /** @@ -60,7 +60,7 @@ type ResolverReturn< * with `resolverExecutedTrigger`. If explicitly set to false while an executor uses this * resolver, an error will be thrown during apply. * - * `auth` declares the resolver's access requirement, checked against `context.user` (the + * `permission` declares the resolver's access requirement, checked against `context.user` (the * original caller, unaffected by `authInvoker`) before `body` runs. Omitted (default): * unchanged, anonymous callers can reach the resolver. `"public"`: explicitly documents that * anonymous callers are allowed. An array of `{ conditions, permit }` policies (in the same @@ -77,7 +77,7 @@ type ResolverReturn< * export default createResolver({ * name: "getUser", * operation: "query", - * auth: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], + * permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }], * input: { * id: t.string(), * }, @@ -97,13 +97,13 @@ export function createResolver< Input extends Record | undefined = undefined, Output extends TailorAnyField | Record = TailorAnyField, >( - config: Omit & + config: Omit & Readonly<{ input?: Input; output: Output; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; - auth?: ResolverPermission | "public"; + permission?: ResolverPermission | "public"; }>, ): ResolverReturn { // Check if output is already a TailorField using duck typing. diff --git a/packages/sdk/src/parser/service/resolver/schema.test.ts b/packages/sdk/src/parser/service/resolver/schema.test.ts index e6e9d5bf89..8a9b7eec0d 100644 --- a/packages/sdk/src/parser/service/resolver/schema.test.ts +++ b/packages/sdk/src/parser/service/resolver/schema.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "vitest"; -import { ResolverAuthSchema } from "./schema"; +import { ResolverPermissionSchema } from "./schema"; -describe("ResolverAuthSchema", () => { +describe("ResolverPermissionSchema", () => { test("accepts a single policy with one condition", () => { expect(() => - ResolverAuthSchema.parse([ + ResolverPermissionSchema.parse([ { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, ]), ).not.toThrow(); @@ -12,7 +12,7 @@ describe("ResolverAuthSchema", () => { test("accepts a single policy with multiple conditions", () => { expect(() => - ResolverAuthSchema.parse([ + ResolverPermissionSchema.parse([ { conditions: [ [{ user: "_loggedIn" }, "=", true], @@ -26,7 +26,7 @@ describe("ResolverAuthSchema", () => { test("accepts multiple policies", () => { expect(() => - ResolverAuthSchema.parse([ + ResolverPermissionSchema.parse([ { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, ]), @@ -34,22 +34,24 @@ describe("ResolverAuthSchema", () => { }); test('accepts "public"', () => { - expect(() => ResolverAuthSchema.parse("public")).not.toThrow(); + expect(() => ResolverPermissionSchema.parse("public")).not.toThrow(); }); test("rejects an empty policy array", () => { - expect(() => ResolverAuthSchema.parse([])).toThrow("auth must have at least one policy"); + expect(() => ResolverPermissionSchema.parse([])).toThrow( + "permission must have at least one policy", + ); }); test("rejects a policy with an empty conditions array", () => { - expect(() => ResolverAuthSchema.parse([{ conditions: [], permit: true }])).toThrow( + expect(() => ResolverPermissionSchema.parse([{ conditions: [], permit: true }])).toThrow( "must have at least one condition", ); }); test("rejects a policy missing permit", () => { expect(() => - ResolverAuthSchema.parse([{ conditions: [[{ user: "_loggedIn" }, "=", true]] }]), + ResolverPermissionSchema.parse([{ conditions: [[{ user: "_loggedIn" }, "=", true]] }]), ).toThrow("permit"); }); }); diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index 8301bb5ff8..270f5741f1 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -28,18 +28,18 @@ const ResolverPermissionPolicySchema = z.object({ ResolverPermissionConditionSchema, z .array(ResolverPermissionConditionSchema) - .min(1, "Resolver auth policy must have at least one condition") + .min(1, "Resolver permission policy must have at least one condition") .readonly(), ]), permit: z.boolean(), description: z.string().optional(), }); -export const ResolverAuthSchema = z +export const ResolverPermissionSchema = z .union([ z .array(ResolverPermissionPolicySchema) - .min(1, "Resolver auth must have at least one policy") + .min(1, "Resolver permission must have at least one policy") .readonly(), z.literal("public"), ]) @@ -58,5 +58,5 @@ export const ResolverSchema = z.object({ output: TailorFieldSchema.describe("Output field definition"), publishEvents: z.boolean().optional().describe("Enable publishing events from this resolver"), authInvoker: AuthInvokerSchema.optional().describe("Machine user to execute this resolver as"), - auth: ResolverAuthSchema.optional(), + permission: ResolverPermissionSchema.optional(), }); diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index 7ce0991332..29fb2cc5b0 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -3,7 +3,7 @@ /** * Access requirement for this resolver, evaluated against the original caller (unaffected by `authInvoker`) before `body` runs. "public" documents that anonymous callers are allowed. Omitted (default): unchanged, anonymous callers can reach the resolver */ -export type ResolverAuth = +export type ResolverPermission = | "public" | readonly { conditions: @@ -20,7 +20,7 @@ export type ResolverAuth = permit: boolean; description?: string | undefined; }[]; -export type ResolverAuthInput = ResolverAuth; +export type ResolverPermissionInput = ResolverPermission; export type Resolver = { /** GraphQL operation type (query or mutation) */ @@ -85,7 +85,7 @@ export type Resolver = { machineUserName: string; } | undefined; - auth?: + permission?: | "public" | readonly { conditions: From ccf777fcff7b70eb7cb37548b2e8a88655a50718 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Wed, 15 Jul 2026 16:59:36 +0900 Subject: [PATCH 06/11] fix(resolver): correct denial-reason attribution in permission guard Address dqn review feedback on PR #1718 (this repo): - The thrown TailorErrorMessage previously concatenated the description of every policy in `permission`, regardless of which one actually caused the denial. A caller denied purely for failing the allow-list (e.g. not logged in) would also see unrelated `permit: false` policy descriptions in the message. Now the guard compiles each policy to a `{ matched, description }` entry and only joins the description(s) of the policy/policies that actually matched at runtime. - Deduplicate the operand-key-extraction type helpers (StringFieldKeys/BooleanFieldKeys/UserStringOperand/UserBooleanOperand and their array variants), which were copied verbatim across tailordb/idp/resolver permission.ts, into a new shared pure type module: configure/types/permission-operand.types.ts. --- .../sdk/src/cli/shared/runtime-exprs.test.ts | 30 ++++++++++++ packages/sdk/src/cli/shared/runtime-exprs.ts | 48 +++++++++++-------- .../src/configure/services/idp/permission.ts | 38 +++------------ .../configure/services/resolver/permission.ts | 23 +++------ .../configure/services/tailordb/permission.ts | 39 +++------------ .../types/permission-operand.types.ts | 41 ++++++++++++++++ 6 files changed, 118 insertions(+), 101 deletions(-) create mode 100644 packages/sdk/src/configure/types/permission-operand.types.ts diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index 478d5918e1..87dd44f01e 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -254,6 +254,36 @@ describe("buildResolverPermissionGuardExpr", () => { expect(() => runGuard(permission, { type: "" })).toThrow(/must be logged in/); }); + test("only includes the description of the policy that actually caused the denial", () => { + const permission = [ + { + conditions: [[{ user: "_loggedIn" }, "=", true]], + permit: true, + description: "must be logged in", + }, + { + conditions: [[{ user: "role" }, "=", "BANNED"]], + permit: false, + description: "banned users are rejected", + }, + ] as const; + + // Denied for failing the allow-list (not logged in) — only that policy's + // description should appear, not the unrelated deny policy's. + expect(() => runGuard(permission, { type: "" })).toThrow("access denied: must be logged in"); + + // Denied for matching the deny policy (logged in but banned) — only that + // policy's description should appear, not the unrelated allow policy's. + expect(() => runGuard(permission, { type: "user", attributes: { role: "BANNED" } })).toThrow( + "access denied: banned users are rejected", + ); + + // Allowed: logged in and not banned. + expect(() => + runGuard(permission, { type: "user", attributes: { role: "MEMBER" } }), + ).not.toThrow(); + }); + test("throws at bundle time on an empty permission array (schema should reject this, but guard defensively too)", () => { expect(() => buildResolverPermissionGuardExpr([])).toThrow(/at least one policy/); }); diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index a06c00f644..ede1906c41 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -150,6 +150,16 @@ function resolverPermissionPolicyExpr(policy: ResolverPermissionPolicy): string return conditions.map(resolverPermissionConditionExpr).join(" && "); } +/** + * Build a JS object literal capturing whether `policy` matched and its + * (possibly empty) description, for runtime denial-reason attribution. + * @param policy - The policy to compile + * @returns A JS object literal expression: `{ matched, description }` + */ +function policyEntryExpr(policy: ResolverPermissionPolicy): string { + return `{ matched: ${resolverPermissionPolicyExpr(policy)}, description: ${JSON.stringify(policy.description ?? "")} }`; +} + /** * Build the permission guard statement injected at resolver entry. * @@ -158,9 +168,11 @@ function resolverPermissionPolicyExpr(policy: ResolverPermissionPolicy): string * unaffected by `authInvoker`. A `permit: false` policy always denies matching * callers. With no `permit: true` policy, `permission` is a pure blocklist * (everyone else is allowed); with at least one, it's an allow-list (deny by - * default, granted only by a matching `permit: true` policy). + * default, granted only by a matching `permit: true` policy). The thrown + * message only includes the description(s) of the policy/policies that + * actually caused the denial. * @param permission - The resolver's `permission` config - * @returns A JS `if (...) throw ...;` statement, or `undefined` when `permission` is omitted or `"public"` + * @returns A JS statement, or `undefined` when `permission` is omitted or `"public"` */ export function buildResolverPermissionGuardExpr( permission: Resolver["permission"], @@ -174,21 +186,19 @@ export function buildResolverPermissionGuardExpr( const denyPolicies = permission.filter((policy) => policy.permit === false); const allowPolicies = permission.filter((policy) => policy.permit !== false); - const deniedExpr = - denyPolicies.length > 0 - ? denyPolicies.map((policy) => `(${resolverPermissionPolicyExpr(policy)})`).join(" || ") - : "false"; - - // With no allow policies, `permission` is a pure blocklist: deny only callers matching - // a deny policy, allow everyone else. With at least one allow policy, `permission` is - // an allow-list: deny anyone that doesn't match an allow policy (in addition to - // the deny-policy override above). - const denyExpr = - allowPolicies.length > 0 - ? `(${deniedExpr}) || !(${allowPolicies.map((policy) => `(${resolverPermissionPolicyExpr(policy)})`).join(" || ")})` - : deniedExpr; - const descriptions = permission.map((policy) => policy.description).filter((d) => !!d); - const message = - descriptions.length > 0 ? `access denied: ${descriptions.join("; ")}` : "access denied"; - return `if (${denyExpr}) { throw new TailorErrorMessage(${JSON.stringify(message)}); }`; + const denyEntriesExpr = `[${denyPolicies.map(policyEntryExpr).join(", ")}]`; + const allowEntriesExpr = `[${allowPolicies.map(policyEntryExpr).join(", ")}]`; + + return `{ + const $denyPolicies = ${denyEntriesExpr}; + const $allowPolicies = ${allowEntriesExpr}; + const $matchedDeny = $denyPolicies.filter((p) => p.matched); + const $anyAllowMatched = $allowPolicies.some((p) => p.matched); + if ($matchedDeny.length > 0 || ($allowPolicies.length > 0 && !$anyAllowMatched)) { + const $reasons = ($matchedDeny.length > 0 ? $matchedDeny : $allowPolicies) + .map((p) => p.description) + .filter(Boolean); + throw new TailorErrorMessage($reasons.length > 0 ? "access denied: " + $reasons.join("; ") : "access denied"); + } + }`; } diff --git a/packages/sdk/src/configure/services/idp/permission.ts b/packages/sdk/src/configure/services/idp/permission.ts index 679750708e..f9defcc82d 100644 --- a/packages/sdk/src/configure/services/idp/permission.ts +++ b/packages/sdk/src/configure/services/idp/permission.ts @@ -1,41 +1,15 @@ +import type { + UserBooleanArrayOperand, + UserBooleanOperand, + UserStringArrayOperand, + UserStringOperand, +} from "#/configure/types/permission-operand.types"; import type { IdPUserField } from "#/parser/service/idp/types"; import type { InferredAttributeMap } from "#/runtime/types"; type EqualityOperator = "=" | "!="; type ContainsOperator = "in" | "not in"; -type StringFieldKeys = { - [K in keyof User]: User[K] extends string ? K : never; -}[keyof User]; - -type StringArrayFieldKeys = { - [K in keyof User]: User[K] extends string[] ? K : never; -}[keyof User]; - -type BooleanFieldKeys = { - [K in keyof User]: User[K] extends boolean ? K : never; -}[keyof User]; - -type BooleanArrayFieldKeys = { - [K in keyof User]: User[K] extends boolean[] ? K : never; -}[keyof User]; - -type UserStringOperand = { - user: StringFieldKeys | "id"; -}; - -type UserStringArrayOperand = { - user: StringArrayFieldKeys; -}; - -type UserBooleanOperand = { - user: BooleanFieldKeys | "_loggedIn"; -}; - -type UserBooleanArrayOperand = { - user: BooleanArrayFieldKeys; -}; - type IdPUserOperand = Update extends true ? { oldIdpUser: IdPUserField } | { newIdpUser: IdPUserField } : { idpUser: IdPUserField }; diff --git a/packages/sdk/src/configure/services/resolver/permission.ts b/packages/sdk/src/configure/services/resolver/permission.ts index 9d7a3487aa..cb21cfb644 100644 --- a/packages/sdk/src/configure/services/resolver/permission.ts +++ b/packages/sdk/src/configure/services/resolver/permission.ts @@ -1,23 +1,11 @@ +import type { + UserBooleanOperand, + UserStringOperand, +} from "#/configure/types/permission-operand.types"; import type { InferredAttributeMap } from "#/runtime/types"; type EqualityOperator = "=" | "!="; -type StringFieldKeys = { - [K in keyof User]: User[K] extends string ? K : never; -}[keyof User]; - -type BooleanFieldKeys = { - [K in keyof User]: User[K] extends boolean ? K : never; -}[keyof User]; - -type UserStringOperand = { - user: StringFieldKeys | "id"; -}; - -type UserBooleanOperand = { - user: BooleanFieldKeys | "_loggedIn"; -}; - type StringEqualityCondition = | readonly [UserStringOperand, EqualityOperator, string] | readonly [string, EqualityOperator, UserStringOperand]; @@ -35,7 +23,8 @@ type BooleanEqualityCondition = * * The User type is extended by `tailor.d.ts`, which is automatically generated * when running `tailor-sdk generate`. Attributes enabled in the config file's - * `auth.userProfile.attributes` become available as types. + * `auth.userProfile.attributes` (or `auth.machineUserAttributes` when + * `userProfile` is omitted) become available as types. */ export type ResolverPermissionCondition = | StringEqualityCondition diff --git a/packages/sdk/src/configure/services/tailordb/permission.ts b/packages/sdk/src/configure/services/tailordb/permission.ts index 5562ecebb1..bc70574ee2 100644 --- a/packages/sdk/src/configure/services/tailordb/permission.ts +++ b/packages/sdk/src/configure/services/tailordb/permission.ts @@ -1,3 +1,9 @@ +import type { + UserBooleanArrayOperand, + UserBooleanOperand, + UserStringArrayOperand, + UserStringOperand, +} from "#/configure/types/permission-operand.types"; import type { InferredAttributeMap } from "#/runtime/types"; // --- Permission types (UX-focused, for configure layer) --- @@ -74,39 +80,6 @@ type EqualityOperator = "=" | "!="; type ContainsOperator = "in" | "not in"; type HasAnyOperator = "hasAny" | "not hasAny"; -// Helper types for User field extraction -type StringFieldKeys = { - [K in keyof User]: User[K] extends string ? K : never; -}[keyof User]; - -type StringArrayFieldKeys = { - [K in keyof User]: User[K] extends string[] ? K : never; -}[keyof User]; - -type BooleanFieldKeys = { - [K in keyof User]: User[K] extends boolean ? K : never; -}[keyof User]; - -type BooleanArrayFieldKeys = { - [K in keyof User]: User[K] extends boolean[] ? K : never; -}[keyof User]; - -type UserStringOperand = { - user: StringFieldKeys | "id"; -}; - -type UserStringArrayOperand = { - user: StringArrayFieldKeys; -}; - -type UserBooleanOperand = { - user: BooleanFieldKeys | "_loggedIn"; -}; - -type UserBooleanArrayOperand = { - user: BooleanArrayFieldKeys; -}; - type RecordOperand = Update extends true ? { oldRecord: (keyof Type & string) | "id" } | { newRecord: (keyof Type & string) | "id" } : { record: (keyof Type & string) | "id" }; diff --git a/packages/sdk/src/configure/types/permission-operand.types.ts b/packages/sdk/src/configure/types/permission-operand.types.ts new file mode 100644 index 0000000000..52719a4567 --- /dev/null +++ b/packages/sdk/src/configure/types/permission-operand.types.ts @@ -0,0 +1,41 @@ +// Shared generic operand-key extraction helpers for permission-condition +// systems (TailorDB `.permission()`/`.gqlPermission()`, IdP `permission`, +// resolver `permission`). Each system's `User` type shape differs, but the +// "which keys of `User` hold a string/boolean (array)?" derivation is +// identical, so it lives here once instead of being copied per service. +// +// This is a pure type module: type declarations only, no zod/schema +// references, importable type-only from any layer. +import type { InferredAttributeMap } from "#/runtime/types"; + +export type StringFieldKeys = { + [K in keyof User]: User[K] extends string ? K : never; +}[keyof User]; + +export type StringArrayFieldKeys = { + [K in keyof User]: User[K] extends string[] ? K : never; +}[keyof User]; + +export type BooleanFieldKeys = { + [K in keyof User]: User[K] extends boolean ? K : never; +}[keyof User]; + +export type BooleanArrayFieldKeys = { + [K in keyof User]: User[K] extends boolean[] ? K : never; +}[keyof User]; + +export type UserStringOperand = { + user: StringFieldKeys | "id"; +}; + +export type UserStringArrayOperand = { + user: StringArrayFieldKeys; +}; + +export type UserBooleanOperand = { + user: BooleanFieldKeys | "_loggedIn"; +}; + +export type UserBooleanArrayOperand = { + user: BooleanArrayFieldKeys; +}; From dfa87a1ca34d477d3954e2d6b0befbf143840f21 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Wed, 15 Jul 2026 17:12:56 +0900 Subject: [PATCH 07/11] docs(resolver): rename Authentication heading to Permissions Address remiposo review feedback on PR #1718 (this repo): the `## Authentication` heading grouped the new `permission` field (access-control conditions) and `authInvoker` (execution-identity delegation) under a label that does not match either subsection well. TailorDB's docs already use `### Permissions` for the equivalent `.permission()`/`.gqlPermission()` feature, so rename to `## Permissions` for consistency. --- packages/sdk/docs/services/resolver.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index ab97d3e22b..33ac45bffa 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -350,7 +350,7 @@ createResolver({ }); ``` -## Authentication +## Permissions ### Access Requirement (`permission`) From 69a241ad98c74674a5f3f0ec20ab1caa21287d65 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Wed, 15 Jul 2026 18:38:42 +0900 Subject: [PATCH 08/11] refactor(resolver): rename permission "public" literal to allowAnonymous "public" is a visibility-oriented adjective that does not fit the "permission: X" phrasing and does not match this feature's own docs/ JSDoc, which consistently describe the concept as "anonymous callers are allowed" rather than "public". Rename the sentinel to "allowAnonymous", an action-phrase that both reads naturally as a `permission` value and reuses the existing anonymous-caller vocabulary established elsewhere in this feature. No behavior change. --- .changeset/resolver-permission-field.md | 2 +- packages/sdk/docs/services/resolver.md | 2 +- packages/sdk/src/cli/services/resolver/bundler.test.ts | 4 ++-- packages/sdk/src/cli/shared/runtime-exprs.test.ts | 4 ++-- packages/sdk/src/cli/shared/runtime-exprs.ts | 4 ++-- .../sdk/src/configure/services/resolver/resolver.test.ts | 8 ++++---- packages/sdk/src/configure/services/resolver/resolver.ts | 8 ++++---- packages/sdk/src/parser/service/resolver/schema.test.ts | 4 ++-- packages/sdk/src/parser/service/resolver/schema.ts | 7 ++++--- packages/sdk/src/types/resolver.generated.ts | 6 +++--- 10 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.changeset/resolver-permission-field.md b/.changeset/resolver-permission-field.md index acb308acc9..880520d70d 100644 --- a/.changeset/resolver-permission-field.md +++ b/.changeset/resolver-permission-field.md @@ -2,4 +2,4 @@ "@tailor-platform/sdk": minor --- -Add `permission` field to `createResolver` for declaring a resolver's access requirement, using the same `conditions`/`permit` policy notation as TailorDB's `.permission()` (restricted to `user` operands, e.g. `permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }]`). Rejects non-matching callers before `body` runs. Multiple policies combine like an allow-list with an explicit-deny override. `permission: "public"` explicitly documents that anonymous callers are allowed. Omitting `permission` keeps prior behavior unchanged. +Add `permission` field to `createResolver` for declaring a resolver's access requirement, using the same `conditions`/`permit` policy notation as TailorDB's `.permission()` (restricted to `user` operands, e.g. `permission: [{ conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }]`). Rejects non-matching callers before `body` runs. Multiple policies combine like an allow-list with an explicit-deny override. `permission: "allowAnonymous"` explicitly documents that anonymous callers are allowed. Omitting `permission` keeps prior behavior unchanged. diff --git a/packages/sdk/docs/services/resolver.md b/packages/sdk/docs/services/resolver.md index 33ac45bffa..6d5a50f145 100644 --- a/packages/sdk/docs/services/resolver.md +++ b/packages/sdk/docs/services/resolver.md @@ -388,7 +388,7 @@ permission: [ Besides a policy array, `permission` also accepts: -- `"public"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `permission`, but records the decision so it isn't mistaken for an oversight. +- `"allowAnonymous"` — explicitly documents that anonymous callers are allowed. Behaves the same as omitting `permission`, but records the decision so it isn't mistaken for an oversight. - Omitted (default) — unchanged: anonymous callers can still reach the resolver. This check is based on `context.user`, the original caller, so it still applies even when `authInvoker` swaps in a machine user for database access. diff --git a/packages/sdk/src/cli/services/resolver/bundler.test.ts b/packages/sdk/src/cli/services/resolver/bundler.test.ts index f189489ea5..29b94b4d01 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.test.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.test.ts @@ -71,7 +71,7 @@ describe("bundleResolvers", () => { expect(entryContent).toContain("access denied"); }); - test("does not inject a guard when permission is omitted or public", async () => { + test("does not inject a guard when permission is omitted or allowAnonymous", async () => { using tmp = tempCwd("sdk-bundler-nopermission-"); const resolverDir = path.join(tmp.dir, "src/backend/nopermission/resolver"); fs.mkdirSync(resolverDir, { recursive: true }); @@ -80,7 +80,7 @@ describe("bundleResolvers", () => { `export default {\n` + ` operation: "query",\n` + ` name: "open",\n` + - ` permission: "public",\n` + + ` permission: "allowAnonymous",\n` + ` body: async () => 1,\n` + ` output: { type: "integer", metadata: {}, fields: {} },\n` + `};\n`, diff --git a/packages/sdk/src/cli/shared/runtime-exprs.test.ts b/packages/sdk/src/cli/shared/runtime-exprs.test.ts index 87dd44f01e..8704e5a1f6 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.test.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.test.ts @@ -128,8 +128,8 @@ describe("buildResolverPermissionGuardExpr", () => { expect(buildResolverPermissionGuardExpr(undefined)).toBeUndefined(); }); - test("returns undefined when permission is public", () => { - expect(buildResolverPermissionGuardExpr("public")).toBeUndefined(); + test("returns undefined when permission is allowAnonymous", () => { + expect(buildResolverPermissionGuardExpr("allowAnonymous")).toBeUndefined(); }); test("_loggedIn permit:true allows an authenticated user", () => { diff --git a/packages/sdk/src/cli/shared/runtime-exprs.ts b/packages/sdk/src/cli/shared/runtime-exprs.ts index ede1906c41..a0250b27f5 100644 --- a/packages/sdk/src/cli/shared/runtime-exprs.ts +++ b/packages/sdk/src/cli/shared/runtime-exprs.ts @@ -172,12 +172,12 @@ function policyEntryExpr(policy: ResolverPermissionPolicy): string { * message only includes the description(s) of the policy/policies that * actually caused the denial. * @param permission - The resolver's `permission` config - * @returns A JS statement, or `undefined` when `permission` is omitted or `"public"` + * @returns A JS statement, or `undefined` when `permission` is omitted or `"allowAnonymous"` */ export function buildResolverPermissionGuardExpr( permission: Resolver["permission"], ): string | undefined { - if (!permission || permission === "public") { + if (!permission || permission === "allowAnonymous") { return undefined; } if (permission.length === 0) { diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index 6c52089bac..b4c42eaf0a 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -481,18 +481,18 @@ describe("createResolver", () => { ]); }); - test("creates resolver with permission: public", () => { + test("creates resolver with permission: allowAnonymous", () => { const outputType = t.object({ result: t.string() }); const resolver = createResolver({ - name: "withPermissionPublic", + name: "withPermissionAllowAnonymous", operation: "query", output: outputType, body: () => ({ result: "ok" }), - permission: "public", + permission: "allowAnonymous", }); - expect(resolver.permission).toBe("public"); + expect(resolver.permission).toBe("allowAnonymous"); }); test("creates minimal resolver without optional fields", () => { diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index 03deb7e033..a4701fad27 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -42,7 +42,7 @@ type ResolverReturn< output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; - permission?: ResolverPermission | "public"; + permission?: ResolverPermission | "allowAnonymous"; }>; /** @@ -62,8 +62,8 @@ type ResolverReturn< * * `permission` declares the resolver's access requirement, checked against `context.user` (the * original caller, unaffected by `authInvoker`) before `body` runs. Omitted (default): - * unchanged, anonymous callers can reach the resolver. `"public"`: explicitly documents that - * anonymous callers are allowed. An array of `{ conditions, permit }` policies (in the same + * unchanged, anonymous callers can reach the resolver. `"allowAnonymous"`: explicitly documents + * that anonymous callers are allowed. An array of `{ conditions, permit }` policies (in the same * style as TailorDB's `.permission()`) rejects non-matching callers: with no `permit: true` * policy it's a blocklist (only `permit: false` matches are denied), with at least one it's * an allow-list (denied unless a `permit: true` policy matches). @@ -103,7 +103,7 @@ export function createResolver< output: Output; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; - permission?: ResolverPermission | "public"; + permission?: ResolverPermission | "allowAnonymous"; }>, ): ResolverReturn { // Check if output is already a TailorField using duck typing. diff --git a/packages/sdk/src/parser/service/resolver/schema.test.ts b/packages/sdk/src/parser/service/resolver/schema.test.ts index 8a9b7eec0d..2f05e9dc1b 100644 --- a/packages/sdk/src/parser/service/resolver/schema.test.ts +++ b/packages/sdk/src/parser/service/resolver/schema.test.ts @@ -33,8 +33,8 @@ describe("ResolverPermissionSchema", () => { ).not.toThrow(); }); - test('accepts "public"', () => { - expect(() => ResolverPermissionSchema.parse("public")).not.toThrow(); + test('accepts "allowAnonymous"', () => { + expect(() => ResolverPermissionSchema.parse("allowAnonymous")).not.toThrow(); }); test("rejects an empty policy array", () => { diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index 270f5741f1..9ffa88b8c8 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -41,12 +41,13 @@ export const ResolverPermissionSchema = z .array(ResolverPermissionPolicySchema) .min(1, "Resolver permission must have at least one policy") .readonly(), - z.literal("public"), + z.literal("allowAnonymous"), ]) .describe( "Access requirement for this resolver, evaluated against the original caller " + - '(unaffected by `authInvoker`) before `body` runs. "public" documents that anonymous ' + - "callers are allowed. Omitted (default): unchanged, anonymous callers can reach the resolver", + '(unaffected by `authInvoker`) before `body` runs. "allowAnonymous" documents that ' + + "anonymous callers are allowed. Omitted (default): unchanged, anonymous callers can " + + "reach the resolver", ); export const ResolverSchema = z.object({ diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index 6dfa47047c..8e05ebb557 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -7,10 +7,10 @@ export type QueryType = "query" | "mutation"; export type QueryTypeInput = QueryType; /** - * Access requirement for this resolver, evaluated against the original caller (unaffected by `authInvoker`) before `body` runs. "public" documents that anonymous callers are allowed. Omitted (default): unchanged, anonymous callers can reach the resolver + * Access requirement for this resolver, evaluated against the original caller (unaffected by `authInvoker`) before `body` runs. "allowAnonymous" documents that anonymous callers are allowed. Omitted (default): unchanged, anonymous callers can reach the resolver */ export type ResolverPermission = - | "public" + | "allowAnonymous" | readonly { conditions: | readonly [ @@ -92,7 +92,7 @@ export type Resolver = { } | undefined; permission?: - | "public" + | "allowAnonymous" | readonly { conditions: | readonly [ From 53c2d21c59f251a871dae145454c7c10289499fc Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Wed, 15 Jul 2026 19:09:51 +0900 Subject: [PATCH 09/11] fix(resolver): require a `user` operand and widen ResolverPermission type Reject permission conditions where neither side references `{ user: ... }` (e.g. `["a", "=", "b"]`), matching the configure-layer type which already requires this. Also widen `ResolverPermission` to include the `"allowAnonymous"` sentinel so it matches the field's actual accepted values. --- .../src/configure/services/resolver/permission.ts | 8 ++++++-- .../src/configure/services/resolver/resolver.test.ts | 6 ++++++ .../sdk/src/configure/services/resolver/resolver.ts | 4 ++-- .../sdk/src/parser/service/resolver/schema.test.ts | 6 ++++++ packages/sdk/src/parser/service/resolver/schema.ts | 12 +++++++++++- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/configure/services/resolver/permission.ts b/packages/sdk/src/configure/services/resolver/permission.ts index cb21cfb644..60e2aeddd5 100644 --- a/packages/sdk/src/configure/services/resolver/permission.ts +++ b/packages/sdk/src/configure/services/resolver/permission.ts @@ -48,7 +48,8 @@ export type ResolverPermissionPolicy * A `permit: false` policy always denies matching callers. With no * `permit: true` policy, this is a pure blocklist (everyone else is allowed); * with at least one, it's an allow-list (deny by default, granted only by a - * matching `permit: true` policy). + * matching `permit: true` policy). `"allowAnonymous"` explicitly documents + * that anonymous callers are allowed. * @example * const permission: ResolverPermission = [ * { conditions: [[{ user: "_loggedIn" }, "=", true]], permit: true }, @@ -59,6 +60,9 @@ export type ResolverPermissionPolicy * { conditions: [[{ user: "isServiceAccount" }, "=", true]], permit: true }, * { conditions: [[{ user: "role" }, "=", "ADMIN"]], permit: true }, * ]; + * @example + * const permission: ResolverPermission = "allowAnonymous"; */ export type ResolverPermission = - readonly ResolverPermissionPolicy[]; + | readonly ResolverPermissionPolicy[] + | "allowAnonymous"; diff --git a/packages/sdk/src/configure/services/resolver/resolver.test.ts b/packages/sdk/src/configure/services/resolver/resolver.test.ts index b4c42eaf0a..4b0a184f99 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.test.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.test.ts @@ -5,6 +5,7 @@ import { createResolver } from "./resolver"; import type { TailorInvoker, TailorUser } from "#/runtime/types"; import type { output } from "#/types/helpers"; import type { ResolverInput } from "#/types/resolver.generated"; +import type { ResolverPermission } from "./permission"; describe("createResolver", () => { describe("type inference", () => { @@ -495,6 +496,11 @@ describe("createResolver", () => { expect(resolver.permission).toBe("allowAnonymous"); }); + test("ResolverPermission type accepts a standalone allowAnonymous constant", () => { + const permission: ResolverPermission = "allowAnonymous"; + expect(permission).toBe("allowAnonymous"); + }); + test("creates minimal resolver without optional fields", () => { const outputType = t.object({ result: t.string() }); diff --git a/packages/sdk/src/configure/services/resolver/resolver.ts b/packages/sdk/src/configure/services/resolver/resolver.ts index a4701fad27..8c27dae87a 100644 --- a/packages/sdk/src/configure/services/resolver/resolver.ts +++ b/packages/sdk/src/configure/services/resolver/resolver.ts @@ -42,7 +42,7 @@ type ResolverReturn< output: NormalizedOutput; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; - permission?: ResolverPermission | "allowAnonymous"; + permission?: ResolverPermission; }>; /** @@ -103,7 +103,7 @@ export function createResolver< output: Output; body: (context: Context) => OutputType | Promise>; authInvoker?: AuthInvoker | MachineUserName; - permission?: ResolverPermission | "allowAnonymous"; + permission?: ResolverPermission; }>, ): ResolverReturn { // Check if output is already a TailorField using duck typing. diff --git a/packages/sdk/src/parser/service/resolver/schema.test.ts b/packages/sdk/src/parser/service/resolver/schema.test.ts index 2f05e9dc1b..f7725c5329 100644 --- a/packages/sdk/src/parser/service/resolver/schema.test.ts +++ b/packages/sdk/src/parser/service/resolver/schema.test.ts @@ -54,4 +54,10 @@ describe("ResolverPermissionSchema", () => { ResolverPermissionSchema.parse([{ conditions: [[{ user: "_loggedIn" }, "=", true]] }]), ).toThrow("permit"); }); + + test("rejects a condition with no `user` operand on either side", () => { + expect(() => + ResolverPermissionSchema.parse([{ conditions: [["a", "=", "b"]], permit: true }]), + ).toThrow("must reference a `user` operand"); + }); }); diff --git a/packages/sdk/src/parser/service/resolver/schema.ts b/packages/sdk/src/parser/service/resolver/schema.ts index 9ffa88b8c8..2652e94449 100644 --- a/packages/sdk/src/parser/service/resolver/schema.ts +++ b/packages/sdk/src/parser/service/resolver/schema.ts @@ -15,12 +15,19 @@ const ResolverPermissionOperandSchema = z.union([ const ResolverPermissionOperatorSchema = z.union([z.literal("="), z.literal("!=")]); +const isUserOperand = (operand: z.infer) => + typeof operand === "object"; + const ResolverPermissionConditionSchema = z .tuple([ ResolverPermissionOperandSchema, ResolverPermissionOperatorSchema, ResolverPermissionOperandSchema, ]) + .refine( + ([left, , right]) => isUserOperand(left) || isUserOperand(right), + "Resolver permission condition must reference a `user` operand on at least one side", + ) .readonly(); const ResolverPermissionPolicySchema = z.object({ @@ -32,7 +39,10 @@ const ResolverPermissionPolicySchema = z.object({ .readonly(), ]), permit: z.boolean(), - description: z.string().optional(), + description: z + .string() + .optional() + .describe("Reason recorded for this policy, used in the access-denied error message"), }); export const ResolverPermissionSchema = z From 621445327d07dfdb4853994335c6da0a77a35687 Mon Sep 17 00:00:00 2001 From: Akira HIGUCHI Date: Fri, 17 Jul 2026 09:23:12 +0900 Subject: [PATCH 10/11] fix(resolver): update bundler tests for virtual entry after main merge Merging origin/main brought in a refactor that bundles resolvers through an in-memory virtual entry instead of writing a physical `.tailor-sdk/resolvers/*.entry.js` file, so the two permission-guard tests introduced in this branch no longer had a file to read. Read the bundled code from bundleResolvers()'s returned map instead, matching the pattern already used by the other bundler tests. The permission-guard assertion also switched from matching the unminified `context.user.type !== ""` literal to `user.type!==`, since real (non-mocked) minification renames locals and normalizes string-literal quoting. --- .../src/cli/services/resolver/bundler.test.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/sdk/src/cli/services/resolver/bundler.test.ts b/packages/sdk/src/cli/services/resolver/bundler.test.ts index dd6ffbeec5..dc3c68d451 100644 --- a/packages/sdk/src/cli/services/resolver/bundler.test.ts +++ b/packages/sdk/src/cli/services/resolver/bundler.test.ts @@ -135,18 +135,16 @@ describe("bundleResolvers", () => { `};\n`, ); - await bundleResolvers( + const result = await bundleResolvers( "permissioncheck", { files: ["./src/backend/permissioncheck/resolver/*.ts"] }, tmp.dir, ); - const entryContent = fs.readFileSync( - path.join(tmp.dir, ".tailor-sdk/resolvers/protected.entry.js"), - "utf-8", - ); + const entryContent = result.get("protected"); - expect(entryContent).toContain('context.user.type !== ""'); + expect(entryContent).toBeDefined(); + expect(entryContent).toContain("user.type!=="); expect(entryContent).toContain("TailorErrorMessage"); expect(entryContent).toContain("access denied"); }); @@ -166,17 +164,15 @@ describe("bundleResolvers", () => { `};\n`, ); - await bundleResolvers( + const result = await bundleResolvers( "nopermission", { files: ["./src/backend/nopermission/resolver/*.ts"] }, tmp.dir, ); - const entryContent = fs.readFileSync( - path.join(tmp.dir, ".tailor-sdk/resolvers/open.entry.js"), - "utf-8", - ); + const entryContent = result.get("open"); + expect(entryContent).toBeDefined(); expect(entryContent).not.toContain("TailorErrorMessage"); }); From 9be4f72e56d9ab6285d620bb3008ac921b043d1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 09:20:04 +0000 Subject: [PATCH 11/11] chore(deps): bump zinfer to 0.2.7 Fixes a bug where .describe() JSDoc text on a nested field was silently replaced by an unrelated same-named field's text elsewhere in the schema file, mislabeling generated types such as the new resolver permission policy description. https://github.com/toiroakr/zinfer/issues/340 --- packages/sdk/package.json | 2 +- packages/sdk/src/types/auth.generated.ts | 258 +++++++++++++++++- packages/sdk/src/types/executor.generated.ts | 21 ++ packages/sdk/src/types/field.generated.ts | 20 ++ .../sdk/src/types/http-adapter.generated.ts | 10 + packages/sdk/src/types/idp.generated.ts | 46 ++++ packages/sdk/src/types/resolver.generated.ts | 18 +- packages/sdk/src/types/tailordb.generated.ts | 26 +- pnpm-lock.yaml | 10 +- 9 files changed, 398 insertions(+), 13 deletions(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e807623731..e7a2d62f9a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -236,7 +236,7 @@ "tsdown": "0.22.5", "typescript": "6.0.3", "vitest": "4.1.10", - "zinfer": "0.2.5" + "zinfer": "0.2.7" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", diff --git a/packages/sdk/src/types/auth.generated.ts b/packages/sdk/src/types/auth.generated.ts index 1e48f45465..77461fa106 100644 --- a/packages/sdk/src/types/auth.generated.ts +++ b/packages/sdk/src/types/auth.generated.ts @@ -19,7 +19,9 @@ export type OIDC = { clientID: string; /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; /** OIDC provider URL */ @@ -155,7 +157,9 @@ export type SCIMAuthorization = { /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; @@ -225,17 +229,25 @@ export type SCIMResource = { tailorDBType: string; /** Core SCIM schema definition */ coreSchema: { - /** SCIM resource name */ + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; - /** SCIM resource name */ + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; @@ -267,9 +279,12 @@ export type TenantProviderInput = TenantProvider; export type AuthConfigInput = | { + /** Auth service name */ name: string; + /** Auth hooks */ hooks?: | { + /** Before login auth hook */ beforeLogin?: | { handler: Function; @@ -278,6 +293,7 @@ export type AuthConfigInput = | undefined; } | undefined; + /** Machine user definitions */ machineUsers?: | { [x: string]: { @@ -290,6 +306,7 @@ export type AuthConfigInput = }; } | undefined; + /** OAuth2 client definitions */ oauth2Clients?: | { [x: string]: { @@ -308,86 +325,138 @@ export type AuthConfigInput = }; } | undefined; + /** Identity provider configuration */ idProvider?: | { + /** Identity provider name */ name: string; kind: "SAML"; + /** Enable signing of SAML requests */ enableSignRequest?: boolean | undefined; + /** URL to fetch SAML metadata (mutually exclusive with rawMetadata) */ metadataURL?: string | undefined; + /** Raw SAML metadata XML (mutually exclusive with metadataURL) */ rawMetadata?: string | undefined; + /** URL to redirect to when SAML ACS receives a response with an empty RelayState. */ defaultRedirectURL?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "OIDC"; + /** OAuth2 client ID */ clientID: string; + /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; + /** OIDC provider URL */ providerURL: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "IDToken"; + /** OIDC provider URL */ providerURL: string; + /** OAuth2 client ID */ clientID: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "BuiltInIdP"; + /** IdP namespace */ namespace: string; + /** OAuth2 client name in the IdP */ clientName: string; } | undefined; + /** SCIM provisioning configuration */ scim?: | { + /** Machine user name for SCIM operations */ machineUserName: string; + /** SCIM authorization configuration */ authorization: { + /** SCIM authorization type */ type: "oauth2" | "bearer"; + /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; }; + /** SCIM resource definitions */ resources: { + /** SCIM resource name */ name: string; + /** TailorDB namespace for the resource */ tailorDBNamespace: string; + /** TailorDB type name for the resource */ tailorDBType: string; + /** Core SCIM schema definition */ coreSchema: { + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; }; + /** Attribute mapping configuration */ attributeMapping: { + /** TailorDB field name to map to */ tailorDBField: string; + /** SCIM attribute path */ scimPath: string; }[]; }[]; } | undefined; + /** Multi-tenant provider configuration */ tenantProvider?: | { + /** TailorDB namespace for the tenant type */ namespace: string; + /** TailorDB type name for tenants */ type: string; + /** Field used as the tenant signature */ signatureField: string; } | undefined; + /** Auth connection definitions for external OAuth2 providers */ connections?: | { [x: string]: { @@ -401,7 +470,9 @@ export type AuthConfigInput = }; } | undefined; + /** Enable publishing session events */ publishSessionEvents?: boolean | undefined; + /** User profile configuration */ userProfile?: | { type: { @@ -418,6 +489,7 @@ export type AuthConfigInput = _output: any; }; usernameField: string; + /** TailorDB namespace where the user type is defined */ namespace?: string | undefined; attributes?: | { @@ -427,10 +499,13 @@ export type AuthConfigInput = attributeList?: string[] | undefined; } | undefined; + /** Machine user attribute fields */ machineUserAttributes?: undefined; } | { + /** Auth service name */ name: string; + /** Machine user attribute fields */ machineUserAttributes: { [x: string]: { type: @@ -466,8 +541,10 @@ export type AuthConfigInput = fields: any; }; }; + /** Auth hooks */ hooks?: | { + /** Before login auth hook */ beforeLogin?: | { handler: Function; @@ -476,6 +553,7 @@ export type AuthConfigInput = | undefined; } | undefined; + /** Machine user definitions */ machineUsers?: | { [x: string]: { @@ -488,6 +566,7 @@ export type AuthConfigInput = }; } | undefined; + /** OAuth2 client definitions */ oauth2Clients?: | { [x: string]: { @@ -506,86 +585,138 @@ export type AuthConfigInput = }; } | undefined; + /** Identity provider configuration */ idProvider?: | { + /** Identity provider name */ name: string; kind: "SAML"; + /** Enable signing of SAML requests */ enableSignRequest?: boolean | undefined; + /** URL to fetch SAML metadata (mutually exclusive with rawMetadata) */ metadataURL?: string | undefined; + /** Raw SAML metadata XML (mutually exclusive with metadataURL) */ rawMetadata?: string | undefined; + /** URL to redirect to when SAML ACS receives a response with an empty RelayState. */ defaultRedirectURL?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "OIDC"; + /** OAuth2 client ID */ clientID: string; + /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; + /** OIDC provider URL */ providerURL: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "IDToken"; + /** OIDC provider URL */ providerURL: string; + /** OAuth2 client ID */ clientID: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "BuiltInIdP"; + /** IdP namespace */ namespace: string; + /** OAuth2 client name in the IdP */ clientName: string; } | undefined; + /** SCIM provisioning configuration */ scim?: | { + /** Machine user name for SCIM operations */ machineUserName: string; + /** SCIM authorization configuration */ authorization: { + /** SCIM authorization type */ type: "oauth2" | "bearer"; + /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; }; + /** SCIM resource definitions */ resources: { + /** SCIM resource name */ name: string; + /** TailorDB namespace for the resource */ tailorDBNamespace: string; + /** TailorDB type name for the resource */ tailorDBType: string; + /** Core SCIM schema definition */ coreSchema: { + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; }; + /** Attribute mapping configuration */ attributeMapping: { + /** TailorDB field name to map to */ tailorDBField: string; + /** SCIM attribute path */ scimPath: string; }[]; }[]; } | undefined; + /** Multi-tenant provider configuration */ tenantProvider?: | { + /** TailorDB namespace for the tenant type */ namespace: string; + /** TailorDB type name for tenants */ type: string; + /** Field used as the tenant signature */ signatureField: string; } | undefined; + /** Auth connection definitions for external OAuth2 providers */ connections?: | { [x: string]: { @@ -599,15 +730,20 @@ export type AuthConfigInput = }; } | undefined; + /** Enable publishing session events */ publishSessionEvents?: boolean | undefined; + /** User profile configuration */ userProfile?: undefined; }; export type AuthConfig = | { + /** Auth service name */ name: string; + /** Auth hooks */ hooks?: | { + /** Before login auth hook */ beforeLogin?: | { handler: Function; @@ -616,6 +752,7 @@ export type AuthConfig = | undefined; } | undefined; + /** Machine user definitions */ machineUsers?: | { [x: string]: { @@ -628,6 +765,7 @@ export type AuthConfig = }; } | undefined; + /** OAuth2 client definitions */ oauth2Clients?: | { [x: string]: { @@ -656,86 +794,138 @@ export type AuthConfig = }; } | undefined; + /** Identity provider configuration */ idProvider?: | { + /** Identity provider name */ name: string; kind: "SAML"; + /** Enable signing of SAML requests */ enableSignRequest: boolean; + /** URL to fetch SAML metadata (mutually exclusive with rawMetadata) */ metadataURL?: string | undefined; + /** Raw SAML metadata XML (mutually exclusive with metadataURL) */ rawMetadata?: string | undefined; + /** URL to redirect to when SAML ACS receives a response with an empty RelayState. */ defaultRedirectURL?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "OIDC"; + /** OAuth2 client ID */ clientID: string; + /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; + /** OIDC provider URL */ providerURL: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "IDToken"; + /** OIDC provider URL */ providerURL: string; + /** OAuth2 client ID */ clientID: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "BuiltInIdP"; + /** IdP namespace */ namespace: string; + /** OAuth2 client name in the IdP */ clientName: string; } | undefined; + /** SCIM provisioning configuration */ scim?: | { + /** Machine user name for SCIM operations */ machineUserName: string; + /** SCIM authorization configuration */ authorization: { + /** SCIM authorization type */ type: "oauth2" | "bearer"; + /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; }; + /** SCIM resource definitions */ resources: { + /** SCIM resource name */ name: string; + /** TailorDB namespace for the resource */ tailorDBNamespace: string; + /** TailorDB type name for the resource */ tailorDBType: string; + /** Core SCIM schema definition */ coreSchema: { + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; }; + /** Attribute mapping configuration */ attributeMapping: { + /** TailorDB field name to map to */ tailorDBField: string; + /** SCIM attribute path */ scimPath: string; }[]; }[]; } | undefined; + /** Multi-tenant provider configuration */ tenantProvider?: | { + /** TailorDB namespace for the tenant type */ namespace: string; + /** TailorDB type name for tenants */ type: string; + /** Field used as the tenant signature */ signatureField: string; } | undefined; + /** Auth connection definitions for external OAuth2 providers */ connections?: | { [x: string]: { @@ -749,7 +939,9 @@ export type AuthConfig = }; } | undefined; + /** Enable publishing session events */ publishSessionEvents?: boolean | undefined; + /** User profile configuration */ userProfile?: | { type: { @@ -766,6 +958,7 @@ export type AuthConfig = _output: any; }; usernameField: string; + /** TailorDB namespace where the user type is defined */ namespace?: string | undefined; attributes?: | { @@ -775,10 +968,13 @@ export type AuthConfig = attributeList?: string[] | undefined; } | undefined; + /** Machine user attribute fields */ machineUserAttributes?: undefined; } | { + /** Auth service name */ name: string; + /** Machine user attribute fields */ machineUserAttributes: { [x: string]: { type: @@ -814,8 +1010,10 @@ export type AuthConfig = fields: any; }; }; + /** Auth hooks */ hooks?: | { + /** Before login auth hook */ beforeLogin?: | { handler: Function; @@ -824,6 +1022,7 @@ export type AuthConfig = | undefined; } | undefined; + /** Machine user definitions */ machineUsers?: | { [x: string]: { @@ -836,6 +1035,7 @@ export type AuthConfig = }; } | undefined; + /** OAuth2 client definitions */ oauth2Clients?: | { [x: string]: { @@ -864,86 +1064,138 @@ export type AuthConfig = }; } | undefined; + /** Identity provider configuration */ idProvider?: | { + /** Identity provider name */ name: string; kind: "SAML"; + /** Enable signing of SAML requests */ enableSignRequest: boolean; + /** URL to fetch SAML metadata (mutually exclusive with rawMetadata) */ metadataURL?: string | undefined; + /** Raw SAML metadata XML (mutually exclusive with metadataURL) */ rawMetadata?: string | undefined; + /** URL to redirect to when SAML ACS receives a response with an empty RelayState. */ defaultRedirectURL?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "OIDC"; + /** OAuth2 client ID */ clientID: string; + /** OAuth2 client secret */ clientSecret: { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; }; + /** OIDC provider URL */ providerURL: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "IDToken"; + /** OIDC provider URL */ providerURL: string; + /** OAuth2 client ID */ clientID: string; + /** OIDC issuer URL (defaults to providerURL) */ issuerURL?: string | undefined; + /** JWT claim to use as username */ usernameClaim?: string | undefined; } | { + /** Identity provider name */ name: string; kind: "BuiltInIdP"; + /** IdP namespace */ namespace: string; + /** OAuth2 client name in the IdP */ clientName: string; } | undefined; + /** SCIM provisioning configuration */ scim?: | { + /** Machine user name for SCIM operations */ machineUserName: string; + /** SCIM authorization configuration */ authorization: { + /** SCIM authorization type */ type: "oauth2" | "bearer"; + /** Bearer token secret (required for bearer type) */ bearerSecret?: | { + /** Vault name containing the secret */ vaultName: string; + /** Key of the secret in the vault */ secretKey: string; } | undefined; }; + /** SCIM resource definitions */ resources: { + /** SCIM resource name */ name: string; + /** TailorDB namespace for the resource */ tailorDBNamespace: string; + /** TailorDB type name for the resource */ tailorDBType: string; + /** Core SCIM schema definition */ coreSchema: { + /** SCIM schema name */ name: string; + /** Schema attributes */ attributes: { + /** Attribute data type */ type: "string" | "number" | "boolean" | "datetime" | "complex"; + /** Attribute name */ name: string; + /** Attribute description */ description?: string | undefined; + /** Attribute mutability */ mutability?: "readOnly" | "readWrite" | "writeOnly" | undefined; + /** Whether the attribute is required */ required?: boolean | undefined; + /** Whether the attribute can have multiple values */ multiValued?: boolean | undefined; + /** Uniqueness constraint */ uniqueness?: "none" | "server" | "global" | undefined; + /** List of canonical values */ canonicalValues?: string[] | null | undefined; subAttributes?: any[] | null | undefined; }[]; }; + /** Attribute mapping configuration */ attributeMapping: { + /** TailorDB field name to map to */ tailorDBField: string; + /** SCIM attribute path */ scimPath: string; }[]; }[]; } | undefined; + /** Multi-tenant provider configuration */ tenantProvider?: | { + /** TailorDB namespace for the tenant type */ namespace: string; + /** TailorDB type name for tenants */ type: string; + /** Field used as the tenant signature */ signatureField: string; } | undefined; + /** Auth connection definitions for external OAuth2 providers */ connections?: | { [x: string]: { @@ -957,6 +1209,8 @@ export type AuthConfig = }; } | undefined; + /** Enable publishing session events */ publishSessionEvents?: boolean | undefined; + /** User profile configuration */ userProfile?: undefined; }; diff --git a/packages/sdk/src/types/executor.generated.ts b/packages/sdk/src/types/executor.generated.ts index 5ee8615fd1..82e1cda960 100644 --- a/packages/sdk/src/types/executor.generated.ts +++ b/packages/sdk/src/types/executor.generated.ts @@ -102,7 +102,9 @@ export type FunctionOperation = { authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -120,7 +122,9 @@ export type GqlOperationInput = { authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -137,7 +141,9 @@ export type GqlOperation = { authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -214,21 +220,28 @@ export type Executor = { [x: string]: unknown; } | undefined; + /** Auth invoker for the function execution */ authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; } | { kind: "function" | "jobFunction"; + /** Function implementation */ body: Function; + /** Auth invoker for the function execution */ authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -236,20 +249,28 @@ export type Executor = { | { kind: "graphql"; query: string; + /** Target application name for the GraphQL query */ appName?: string | undefined; + /** Function to compute GraphQL variables */ variables?: Function | undefined; + /** Auth invoker for the function execution */ authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; } | { kind: "webhook"; + /** Function returning the webhook URL */ url: Function; + /** Function to compute the request body */ requestBody?: Function | undefined; + /** HTTP headers for the webhook request */ headers?: | { [x: string]: diff --git a/packages/sdk/src/types/field.generated.ts b/packages/sdk/src/types/field.generated.ts index 3653e97349..c9dba79f44 100644 --- a/packages/sdk/src/types/field.generated.ts +++ b/packages/sdk/src/types/field.generated.ts @@ -16,21 +16,31 @@ export type TailorFieldInput = { | "nested"; /** Field metadata configuration */ metadata: { + /** Whether the field is required */ required?: boolean | undefined; + /** Whether the field is an array */ array?: boolean | undefined; + /** Field description */ description?: string | undefined; + /** Allowed values for enum fields */ allowedValues?: | { + /** The allowed value */ value: string; + /** Description of the allowed value */ description?: string | undefined; }[] | undefined; + /** Lifecycle hooks */ hooks?: | { + /** Hook function called on creation */ create?: Function | undefined; + /** Hook function called on update */ update?: Function | undefined; } | undefined; + /** Type name for nested or enum fields */ typeName?: string | undefined; }; fields: { @@ -54,21 +64,31 @@ export type TailorField = { | "nested"; /** Field metadata configuration */ metadata: { + /** Whether the field is required */ required?: boolean | undefined; + /** Whether the field is an array */ array?: boolean | undefined; + /** Field description */ description?: string | undefined; + /** Allowed values for enum fields */ allowedValues?: | { + /** The allowed value */ value: string; + /** Description of the allowed value */ description?: string | undefined; }[] | undefined; + /** Lifecycle hooks */ hooks?: | { + /** Hook function called on creation */ create?: Function | undefined; + /** Hook function called on update */ update?: Function | undefined; } | undefined; + /** Type name for nested or enum fields */ typeName?: string | undefined; }; fields: { diff --git a/packages/sdk/src/types/http-adapter.generated.ts b/packages/sdk/src/types/http-adapter.generated.ts index c02c13422b..1cee2d140a 100644 --- a/packages/sdk/src/types/http-adapter.generated.ts +++ b/packages/sdk/src/types/http-adapter.generated.ts @@ -7,10 +7,15 @@ export type HttpAdapterConfigInput = { pathPattern: string; /** Per-method functions that transform HTTP requests to GraphQL requests */ input: { + /** Handler for GET requests */ get?: Function | undefined; + /** Handler for POST requests */ post?: Function | undefined; + /** Handler for PUT requests */ put?: Function | undefined; + /** Handler for PATCH requests */ patch?: Function | undefined; + /** Handler for DELETE requests */ delete?: Function | undefined; }; /** Whether the adapter is active */ @@ -32,10 +37,15 @@ export type HttpAdapterConfig = { priority: number; /** Per-method functions that transform HTTP requests to GraphQL requests */ input: { + /** Handler for GET requests */ get?: Function | undefined; + /** Handler for POST requests */ post?: Function | undefined; + /** Handler for PUT requests */ put?: Function | undefined; + /** Handler for PATCH requests */ patch?: Function | undefined; + /** Handler for DELETE requests */ delete?: Function | undefined; }; /** Function that transforms GraphQL response to HTTP response */ diff --git a/packages/sdk/src/types/idp.generated.ts b/packages/sdk/src/types/idp.generated.ts index 32225103de..4fd34bf071 100644 --- a/packages/sdk/src/types/idp.generated.ts +++ b/packages/sdk/src/types/idp.generated.ts @@ -17,12 +17,19 @@ export type IdPGqlOperationsInput = }; export type IdPGqlOperations = { + /** Enable _createUser mutation (default: true) */ create?: boolean | undefined; + /** Enable _updateUser mutation (default: true) */ update?: boolean | undefined; + /** Enable _deleteUser mutation (default: true) */ delete?: boolean | undefined; + /** Enable _users and _user queries (default: true) */ read?: boolean | undefined; + /** Enable _sendPasswordResetEmail mutation (default: true) */ sendPasswordResetEmail?: boolean | undefined; + /** Enable _requestMfaSettingsUrl query (default: true) */ requestMfaSettingsUrl?: boolean | undefined; + /** Enable _unenrollMfa mutation (default: true) */ unenrollMfa?: boolean | undefined; }; @@ -1344,21 +1351,37 @@ export type IdPInput = { /** User authentication policy configuration */ userAuthPolicy?: | { + /** Use non-email identifier for usernames */ useNonEmailIdentifier?: boolean | undefined; + /** Allow users to reset their own passwords */ allowSelfPasswordReset?: boolean | undefined; + /** Require uppercase letters in passwords */ passwordRequireUppercase?: boolean | undefined; + /** Require lowercase letters in passwords */ passwordRequireLowercase?: boolean | undefined; + /** Require non-alphanumeric characters in passwords */ passwordRequireNonAlphanumeric?: boolean | undefined; + /** Require numeric characters in passwords */ passwordRequireNumeric?: boolean | undefined; + /** Minimum password length (6-30) */ passwordMinLength?: number | undefined; + /** Maximum password length (6-4096) */ passwordMaxLength?: number | undefined; + /** Restrict registration to these email domains */ allowedEmailDomains?: string[] | undefined; + /** Enable Google OAuth login */ allowGoogleOauth?: boolean | undefined; + /** Enable Microsoft OAuth login */ allowMicrosoftOauth?: boolean | undefined; + /** Disable password-based authentication */ disablePasswordAuth?: boolean | undefined; + /** Make TOTP MFA available for users in this namespace */ enableMfa?: boolean | undefined; + /** Require TOTP MFA enrollment and challenge for password-authenticated users (requires enableMfa) */ requireMfa?: boolean | undefined; + /** Application origins (scheme + host + optional port) allowed as MFA self-service return targets */ allowedReturnOrigins?: string[] | undefined; + /** Label shown next to the user account in authenticator apps */ mfaIssuer?: string | undefined; } | undefined; @@ -1368,12 +1391,19 @@ export type IdPInput = { gqlOperations?: | "query" | { + /** Enable _createUser mutation (default: true) */ create?: boolean | undefined; + /** Enable _updateUser mutation (default: true) */ update?: boolean | undefined; + /** Enable _deleteUser mutation (default: true) */ delete?: boolean | undefined; + /** Enable _users and _user queries (default: true) */ read?: boolean | undefined; + /** Enable _sendPasswordResetEmail mutation (default: true) */ sendPasswordResetEmail?: boolean | undefined; + /** Enable _requestMfaSettingsUrl query (default: true) */ requestMfaSettingsUrl?: boolean | undefined; + /** Enable _unenrollMfa mutation (default: true) */ unenrollMfa?: boolean | undefined; } | undefined; @@ -1401,21 +1431,37 @@ export type IdP = { /** User authentication policy configuration */ userAuthPolicy?: | { + /** Use non-email identifier for usernames */ useNonEmailIdentifier?: boolean | undefined; + /** Allow users to reset their own passwords */ allowSelfPasswordReset?: boolean | undefined; + /** Require uppercase letters in passwords */ passwordRequireUppercase?: boolean | undefined; + /** Require lowercase letters in passwords */ passwordRequireLowercase?: boolean | undefined; + /** Require non-alphanumeric characters in passwords */ passwordRequireNonAlphanumeric?: boolean | undefined; + /** Require numeric characters in passwords */ passwordRequireNumeric?: boolean | undefined; + /** Minimum password length (6-30) */ passwordMinLength?: number | undefined; + /** Maximum password length (6-4096) */ passwordMaxLength?: number | undefined; + /** Restrict registration to these email domains */ allowedEmailDomains?: string[] | undefined; + /** Enable Google OAuth login */ allowGoogleOauth?: boolean | undefined; + /** Enable Microsoft OAuth login */ allowMicrosoftOauth?: boolean | undefined; + /** Disable password-based authentication */ disablePasswordAuth?: boolean | undefined; + /** Make TOTP MFA available for users in this namespace */ enableMfa?: boolean | undefined; + /** Require TOTP MFA enrollment and challenge for password-authenticated users (requires enableMfa) */ requireMfa?: boolean | undefined; + /** Application origins (scheme + host + optional port) allowed as MFA self-service return targets */ allowedReturnOrigins?: string[] | undefined; + /** Label shown next to the user account in authenticator apps */ mfaIssuer?: string | undefined; } | undefined; diff --git a/packages/sdk/src/types/resolver.generated.ts b/packages/sdk/src/types/resolver.generated.ts index 8e05ebb557..fca9ed1d18 100644 --- a/packages/sdk/src/types/resolver.generated.ts +++ b/packages/sdk/src/types/resolver.generated.ts @@ -37,6 +37,7 @@ export type Resolver = { body: Function; /** Output field definition */ output: { + /** Field data type */ type: | "string" | "boolean" @@ -49,24 +50,33 @@ export type Resolver = { | "datetime" | "time" | "nested"; + /** Field metadata configuration */ metadata: { + /** Whether the field is required */ required?: boolean | undefined; + /** Whether the field is an array */ array?: boolean | undefined; - /** Resolver description */ + /** Field description */ description?: string | undefined; + /** Allowed values for enum fields */ allowedValues?: | { + /** The allowed value */ value: string; - /** Resolver description */ + /** Description of the allowed value */ description?: string | undefined; }[] | undefined; + /** Lifecycle hooks */ hooks?: | { + /** Hook function called on creation */ create?: Function | undefined; + /** Hook function called on update */ update?: Function | undefined; } | undefined; + /** Type name for nested or enum fields */ typeName?: string | undefined; }; fields: { @@ -87,7 +97,9 @@ export type Resolver = { authInvoker?: | string | { + /** Auth namespace */ namespace: string; + /** Machine user name for authentication */ machineUserName: string; } | undefined; @@ -130,7 +142,7 @@ export type Resolver = { ), ])[]; permit: boolean; - /** Resolver description */ + /** Reason recorded for this policy, used in the access-denied error message */ description?: string | undefined; }[] | undefined; diff --git a/packages/sdk/src/types/tailordb.generated.ts b/packages/sdk/src/types/tailordb.generated.ts index d1ff55fe44..2f0c4c8a45 100644 --- a/packages/sdk/src/types/tailordb.generated.ts +++ b/packages/sdk/src/types/tailordb.generated.ts @@ -14,9 +14,13 @@ export type GqlOperationsInput = }; export type GqlOperations = { + /** Enable create mutation (default: true) */ create?: boolean | undefined; + /** Enable update mutation (default: true) */ update?: boolean | undefined; + /** Enable delete mutation (default: true) */ delete?: boolean | undefined; + /** Enable read queries - get, list, aggregation (default: true) */ read?: boolean | undefined; }; @@ -33,7 +37,6 @@ export type DBFieldMetadata = { allowedValues?: | { value: string; - /** Field description */ description?: string | undefined; }[] | undefined; @@ -52,7 +55,9 @@ export type DBFieldMetadata = { /** Lifecycle hooks for the field */ hooks?: | { + /** Hook function called on record creation */ create?: Function | undefined; + /** Hook function called on record update */ update?: Function | undefined; } | undefined; @@ -61,8 +66,11 @@ export type DBFieldMetadata = { /** Serial (auto-increment) configuration */ serial?: | { + /** Starting value for the serial sequence */ start: number; + /** Maximum value for the serial sequence */ maxValue?: number | undefined; + /** Format string for serial value (string type only) */ format?: string | undefined; } | undefined; @@ -75,9 +83,11 @@ export type RawRelationConfig = { /** Relation cardinality type */ type: "1-1" | "n-1" | "keyOnly" | "oneToOne" | "manyToOne" | "N-1"; toward: { - /** Relation cardinality type */ + /** Target type name, or 'self' for self-relations */ type: string; + /** Custom forward relation name */ as?: string | undefined; + /** Target field to join on (default: 'id') */ key?: string | undefined; }; /** Backward relation name on the target type */ @@ -96,9 +106,13 @@ export type TailorDBTypeParsedSettingsInput = { gqlOperations?: | "query" | { + /** Enable create mutation (default: true) */ create?: boolean | undefined; + /** Enable update mutation (default: true) */ update?: boolean | undefined; + /** Enable delete mutation (default: true) */ delete?: boolean | undefined; + /** Enable read queries - get, list, aggregation (default: true) */ read?: boolean | undefined; } | undefined; @@ -1098,7 +1112,9 @@ export type TailorDBServiceConfigInput = { /** Migration configuration */ migration?: | { + /** Directory containing migration files */ directory: string; + /** Machine user name for migration execution */ machineUser?: string | undefined; } | undefined; @@ -1106,9 +1122,13 @@ export type TailorDBServiceConfigInput = { gqlOperations?: | "query" | { + /** Enable create mutation (default: true) */ create?: boolean | undefined; + /** Enable update mutation (default: true) */ update?: boolean | undefined; + /** Enable delete mutation (default: true) */ delete?: boolean | undefined; + /** Enable read queries - get, list, aggregation (default: true) */ read?: boolean | undefined; } | undefined; @@ -1124,7 +1144,9 @@ export type TailorDBServiceConfig = { /** Migration configuration */ migration?: | { + /** Directory containing migration files */ directory: string; + /** Machine user name for migration execution */ machineUser?: string | undefined; } | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3068fd865a..e95ca64c06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -628,8 +628,8 @@ importers: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) zinfer: - specifier: 0.2.5 - version: 0.2.5(typescript@6.0.3)(zod@4.4.3) + specifier: 0.2.7 + version: 0.2.7(typescript@6.0.3)(zod@4.4.3) packages/sdk-codemod: dependencies: @@ -4307,8 +4307,8 @@ packages: yuku-parser@0.5.46: resolution: {integrity: sha512-eMNzX5eYnkqo6zNYf2H8WHcMPHfIf7ijmw0X8NYZ1ANXAU5Y9rwTB9MgfCuvLxlR7fV/96v3gWa8y/YUGFLxjw==} - zinfer@0.2.5: - resolution: {integrity: sha512-SQC0tsLjw4FJvCzL3V0GI1SGNZVR9p/8bFIG0NsUIwbpbOUysRXRBLeTvxruHgDTfNJOObMG2/nfXaMGZn8hHw==} + zinfer@0.2.7: + resolution: {integrity: sha512-D/GL7q03XFPNHeDnsRmfBladuatreu6C31XVKhMCoNsw0FTCZ7+jBnDxdi7//WO8bGP/UonuiISEoX8p/UdrPw==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -7346,7 +7346,7 @@ snapshots: '@yuku-parser/binding-win32-arm64': 0.5.46 '@yuku-parser/binding-win32-x64': 0.5.46 - zinfer@0.2.5(typescript@6.0.3)(zod@4.4.3): + zinfer@0.2.7(typescript@6.0.3)(zod@4.4.3): dependencies: commander: 15.0.0 glob: 13.0.6