cvx-kit 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/foundation/client.d.mts +6 -2
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/docs/commands.md +2 -1
- package/docs/llms.md +1 -1
- package/package.json +2 -8
- package/src/test.ts +7 -5
- package/src/auth.ts +0 -473
- package/src/components/approvals/_generated/api.ts +0 -69
- package/src/components/approvals/_generated/component.ts +0 -335
- package/src/components/approvals/_generated/dataModel.ts +0 -60
- package/src/components/approvals/_generated/server.ts +0 -156
- package/src/components/approvals/audit.ts +0 -148
- package/src/components/approvals/client.ts +0 -321
- package/src/components/approvals/constants.ts +0 -51
- package/src/components/approvals/convex.config.ts +0 -10
- package/src/components/approvals/decisions.ts +0 -376
- package/src/components/approvals/functions.ts +0 -27
- package/src/components/approvals/requests.ts +0 -240
- package/src/components/approvals/schema.ts +0 -23
- package/src/components/approvals/validators.ts +0 -272
- package/src/components/approvals/workflow.ts +0 -204
- package/src/components/approvals/workflow_steps.ts +0 -189
- package/src/components/foundation/_generated/api.ts +0 -62
- package/src/components/foundation/_generated/component.ts +0 -29
- package/src/components/foundation/_generated/dataModel.ts +0 -60
- package/src/components/foundation/_generated/server.ts +0 -156
- package/src/components/foundation/client.ts +0 -301
- package/src/components/foundation/convex.config.ts +0 -3
- package/src/components/foundation/functions.ts +0 -10
- package/src/components/foundation/modules/command/__tests__/command.test.ts +0 -106
- package/src/components/foundation/modules/command/command.ts +0 -154
- package/src/components/foundation/modules/observability/__tests__/observability.test.ts +0 -79
- package/src/components/foundation/modules/observability/observability.ts +0 -95
- package/src/components/foundation/modules/query/__tests__/query.test.ts +0 -25
- package/src/components/foundation/modules/query/query.ts +0 -47
- package/src/components/foundation/result.ts +0 -77
- package/src/components/foundation/schema.ts +0 -4
- package/src/components/foundation/telemetry.ts +0 -23
- package/src/errors.ts +0 -25
- package/src/index.ts +0 -56
- package/src/tenancy.ts +0 -110
- package/src/triggers.ts +0 -124
- package/src/zod-table.ts +0 -283
|
@@ -5,7 +5,7 @@ import { Result, ResultBoundary, TransactionMetricsContext, executeResultBoundar
|
|
|
5
5
|
import { emitSemanticEvent } from "./telemetry.mjs";
|
|
6
6
|
import _default from "./convex.config.mjs";
|
|
7
7
|
declare namespace client_d_exports {
|
|
8
|
-
export { ApplicationCommand, AuditEntryInput, AuditWriter, AuditedOperation, AuditedRegistry, CommandDefaults, CommandExecution, CommandInput, CommandObservation, CommandRegistry, CommandResult, Foundation, FoundationOptions,
|
|
8
|
+
export { ApplicationCommand, AuditEntryInput, AuditWriter, AuditedOperation, AuditedRegistry, CommandDefaults, CommandExecution, CommandInput, CommandObservation, CommandRegistry, CommandResult, Foundation, FoundationOptions, ObservabilityOptions, Parseable, PermissionChecker, QueryExecution, Result, ResultBoundary, TransactionMetricsContext, _default as default };
|
|
9
9
|
}
|
|
10
10
|
/** Shape handed to the injected audit writer; classification comes from the operation. */
|
|
11
11
|
type AuditEntryInput = {
|
|
@@ -123,8 +123,12 @@ declare class Foundation<Component extends FoundationComponentApi = FoundationCo
|
|
|
123
123
|
readonly Command: CommandConstructor;
|
|
124
124
|
readonly Query: typeof Query;
|
|
125
125
|
readonly observability: Observability;
|
|
126
|
+
/** Typed-failure boundary — see result.ts. Facade-bound like the kernels. */
|
|
127
|
+
readonly executeResultBoundary: typeof executeResultBoundary;
|
|
128
|
+
readonly projectResult: typeof projectResult;
|
|
129
|
+
readonly emitSemanticEvent: typeof emitSemanticEvent;
|
|
126
130
|
constructor(component: Component, options: FoundationOptions);
|
|
127
131
|
}
|
|
128
132
|
//#endregion
|
|
129
|
-
export { ApplicationCommand, AuditEntryInput, AuditWriter, AuditedOperation, AuditedRegistry, CommandDefaults, type CommandExecution, type CommandInput, type CommandObservation, type CommandRegistry, type CommandResult, Foundation, FoundationOptions,
|
|
133
|
+
export { ApplicationCommand, AuditEntryInput, AuditWriter, AuditedOperation, AuditedRegistry, CommandDefaults, type CommandExecution, type CommandInput, type CommandObservation, type CommandRegistry, type CommandResult, Foundation, FoundationOptions, type ObservabilityOptions, type Parseable, PermissionChecker, type QueryExecution, type Result, type ResultBoundary, type TransactionMetricsContext, client_d_exports, _default as default };
|
|
130
134
|
//# sourceMappingURL=client.d.mts.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{Command as e}from"./modules/command/command.mjs";import{Observability as t}from"./modules/observability/observability.mjs";import{Query as n}from"./modules/query/query.mjs";import{executeResultBoundary as r,projectResult as i}from"./result.mjs";import{emitSemanticEvent as a}from"./telemetry.mjs";import o from"./convex.config.mjs";var s=class{#e;#t;#n;#r;#i;aggregates;static operation(e){return e}constructor(t,n){this.#t=n.observability,this.#n=n.writeAudit,this.#r=n.checkPermission,this.#i=n.defaults??{},this.aggregates=Object.freeze(Object.fromEntries(Object.entries(t).map(([e,t])=>[e,t.aggregates]))),this.#e=new e({operations:t,execute:e=>this.#a(e)})}exec(e){return this.#e.exec(e)}async#a(e){return this.#t.observe({operation:e.operation,classification:e.definition.classification},async()=>{let t=e.definition.permission;if(t!==void 0){if(!this.#r)throw new l(e.operation);await this.#r(e.context,{permission:t,operation:e.operation})}await this.#i.guard?.(e.context),await e.definition.guard?.(e.context,e.command);let n=await e.run(),r=await e.definition.audit({command:e.command,result:n},e.context);if(r){let t=e.definition.aggregates;if(t&&!t.includes(r.aggregate.type))throw new c(e.operation,r.aggregate.type);await this.#n(e.context,{...r,classification:e.definition.classification})}return n})}},c=class extends Error{code=`COMMAND_AGGREGATE_NOT_DECLARED`;name=`CommandAggregateError`;constructor(e,t){super(`Operation "${e}" audited aggregate type "${t}" outside its declared aggregates`)}},l=class extends Error{code=`COMMAND_PERMISSION_NOT_CONFIGURED`;name=`CommandPermissionError`;constructor(e){super(`Operation "${e}" declares a permission but the Foundation has no checkPermission`)}},u=class{status;Command;Query=n;observability;constructor(e,n){this.status=e.functions.status;let r=new t(n.observability);this.observability=r;let i=n.observability.writeAudit??(()=>void 0),a=n.checkPermission;this.Command=class extends s{constructor(e,t){super(e,{observability:r,writeAudit:i,checkPermission:a,defaults:t})}}}};export{u as Foundation,
|
|
1
|
+
import{Command as e}from"./modules/command/command.mjs";import{Observability as t}from"./modules/observability/observability.mjs";import{Query as n}from"./modules/query/query.mjs";import{executeResultBoundary as r,projectResult as i}from"./result.mjs";import{emitSemanticEvent as a}from"./telemetry.mjs";import o from"./convex.config.mjs";var s=class{#e;#t;#n;#r;#i;aggregates;static operation(e){return e}constructor(t,n){this.#t=n.observability,this.#n=n.writeAudit,this.#r=n.checkPermission,this.#i=n.defaults??{},this.aggregates=Object.freeze(Object.fromEntries(Object.entries(t).map(([e,t])=>[e,t.aggregates]))),this.#e=new e({operations:t,execute:e=>this.#a(e)})}exec(e){return this.#e.exec(e)}async#a(e){return this.#t.observe({operation:e.operation,classification:e.definition.classification},async()=>{let t=e.definition.permission;if(t!==void 0){if(!this.#r)throw new l(e.operation);await this.#r(e.context,{permission:t,operation:e.operation})}await this.#i.guard?.(e.context),await e.definition.guard?.(e.context,e.command);let n=await e.run(),r=await e.definition.audit({command:e.command,result:n},e.context);if(r){let t=e.definition.aggregates;if(t&&!t.includes(r.aggregate.type))throw new c(e.operation,r.aggregate.type);await this.#n(e.context,{...r,classification:e.definition.classification})}return n})}},c=class extends Error{code=`COMMAND_AGGREGATE_NOT_DECLARED`;name=`CommandAggregateError`;constructor(e,t){super(`Operation "${e}" audited aggregate type "${t}" outside its declared aggregates`)}},l=class extends Error{code=`COMMAND_PERMISSION_NOT_CONFIGURED`;name=`CommandPermissionError`;constructor(e){super(`Operation "${e}" declares a permission but the Foundation has no checkPermission`)}},u=class{status;Command;Query=n;observability;executeResultBoundary=r;projectResult=i;emitSemanticEvent=a;constructor(e,n){this.status=e.functions.status;let r=new t(n.observability);this.observability=r;let i=n.observability.writeAudit??(()=>void 0),a=n.checkPermission;this.Command=class extends s{constructor(e,t){super(e,{observability:r,writeAudit:i,checkPermission:a,defaults:t})}}}};export{u as Foundation,o as default};
|
|
2
2
|
//# sourceMappingURL=client.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":["#kernel","#observability","#writeAudit","#checkPermission","#defaults","CommandKernelClass","#execute"],"sources":["../../../src/components/foundation/client.ts"],"sourcesContent":["import {\n\tCommand as CommandKernelClass,\n\ttype CommandExecution,\n\ttype CommandInput,\n\ttype CommandRegistry,\n\ttype CommandResult,\n} from './modules/command/command'\nimport {\n\tObservability,\n\ttype ObservabilityOptions,\n} from './modules/observability/observability'\nimport { Query } from './modules/query/query'\n\n/** Shape handed to the injected audit writer; classification comes from the operation. */\nexport type AuditEntryInput = {\n\toperation: string\n\tactorId: string\n\taggregate: { type: string; id: string }\n\tmetadata?: Record<string, unknown>\n\tclassification: string\n}\n\nexport type AuditWriter = (\n\tcontext: never,\n\tentry: AuditEntryInput,\n) => Promise<unknown> | unknown\n\ntype MaybePromise<Value> = Value | Promise<Value>\ntype OperationKey<Operations extends CommandRegistry> = Extract<\n\tkeyof Operations,\n\tstring\n>\n\nexport type AuditedOperation = Readonly<{\n\tcommand: Readonly<{ parse: (value: unknown) => unknown }>\n\tresult: Readonly<{ parse: (value: unknown) => unknown }>\n\tclassification: string\n\t/**\n\t * Permission slug required to execute this operation. Checked through the\n\t * Foundation's injected checkPermission BEFORE guards and the handler;\n\t * declaring a permission without injecting a checker fails closed.\n\t */\n\tpermission?: string\n\t/**\n\t * Per-operation precondition, after the permission check and the registry\n\t * default guard, before the handler. Throw to deny — nothing has run yet.\n\t */\n\tguard?: (context: never, command: never) => MaybePromise<void>\n\t/**\n\t * Allowlist of aggregate types this operation's audit may reference. When\n\t * declared, an audit entry whose aggregate.type is not listed throws —\n\t * the audit vocabulary is enforced, not advisory.\n\t */\n\taggregates?: readonly string[]\n\taudit: (\n\t\tresolution: Readonly<{ command: never; result: never }>,\n\t\tcontext: never,\n\t) => MaybePromise<Omit<AuditEntryInput, 'classification'> | null>\n}>\n\n/** Registry-wide defaults applied to every operation of one Command. */\nexport type CommandDefaults = Readonly<{\n\t/** Runs before every operation's own guard. Throw to deny. */\n\tguard?: (context: never) => MaybePromise<void>\n}>\n\n/** Injected permission policy: throw to deny. Host semantics, kit ordering. */\nexport type PermissionChecker = (\n\tcontext: never,\n\tinput: Readonly<{ permission: string; operation: string }>,\n) => MaybePromise<void>\n\nexport type AuditedRegistry = Readonly<Record<string, AuditedOperation>>\n\nexport type FoundationOptions = Readonly<{\n\tobservability: ObservabilityOptions &\n\t\tReadonly<{\n\t\t\t/**\n\t\t\t * In-transaction audit writer used by Command. Operations that\n\t\t\t * return an audit record get it written here automatically.\n\t\t\t */\n\t\t\twriteAudit?: AuditWriter\n\t\t}>\n\t/**\n\t * Permission policy for operations that declare a `permission`. Runs\n\t * before guards and the handler; throw to deny. An operation with a\n\t * permission but no injected checker fails closed.\n\t */\n\tcheckPermission?: PermissionChecker\n}>\n\ntype FoundationComponentApi = Readonly<{\n\tfunctions: Readonly<{ status: unknown }>\n}>\n\n/**\n * Application command protocol bound to a Foundation instance:\n * validate, observe, execute, and audit. `classification` and `audit()`\n * are mandatory per operation — auditing is type-enforced, not opt-in.\n * Obtain it by destructuring the Foundation: `const { Command } = new Foundation(...)`.\n */\nclass BoundCommand<Context, const Operations extends AuditedRegistry> {\n\treadonly #kernel: CommandKernelClass<Context, Operations>\n\treadonly #observability: Observability\n\treadonly #writeAudit: AuditWriter\n\treadonly #checkPermission: PermissionChecker | undefined\n\treadonly #defaults: CommandDefaults\n\n\t/** Declared aggregate types per operation — introspectable for tests. */\n\treadonly aggregates: Readonly<{\n\t\t[Key in OperationKey<Operations>]: Operations[Key]['aggregates']\n\t}>\n\n\tstatic operation<const Definition extends AuditedOperation>(\n\t\tdefinition: Definition,\n\t): Definition {\n\t\treturn definition\n\t}\n\n\tconstructor(\n\t\toperations: Operations,\n\t\tdeps: {\n\t\t\tobservability: Observability\n\t\t\twriteAudit: AuditWriter\n\t\t\tcheckPermission?: PermissionChecker\n\t\t\tdefaults?: CommandDefaults\n\t\t},\n\t) {\n\t\tthis.#observability = deps.observability\n\t\tthis.#writeAudit = deps.writeAudit\n\t\tthis.#checkPermission = deps.checkPermission\n\t\tthis.#defaults = deps.defaults ?? {}\n\t\tthis.aggregates = Object.freeze(\n\t\t\tObject.fromEntries(\n\t\t\t\tObject.entries(operations).map(([operation, definition]) => [\n\t\t\t\t\toperation,\n\t\t\t\t\tdefinition.aggregates,\n\t\t\t\t]),\n\t\t\t),\n\t\t) as this['aggregates']\n\t\tthis.#kernel = new CommandKernelClass<Context, Operations>({\n\t\t\toperations,\n\t\t\texecute: (execution) => this.#execute(execution),\n\t\t})\n\t}\n\n\texec<const Key extends OperationKey<Operations>>(executor: {\n\t\toperation: Key\n\t\thandler: (\n\t\t\tcontext: Context,\n\t\t\tcommand: CommandInput<Operations, Key>,\n\t\t) => MaybePromise<CommandResult<Operations, Key>>\n\t}) {\n\t\treturn this.#kernel.exec(executor)\n\t}\n\n\tasync #execute<Key extends OperationKey<Operations>>(\n\t\texecution: CommandExecution<Context, Operations, Key>,\n\t): Promise<CommandResult<Operations, Key>> {\n\t\treturn this.#observability.observe(\n\t\t\t{\n\t\t\t\toperation: execution.operation,\n\t\t\t\tclassification: execution.definition.classification,\n\t\t\t},\n\t\t\tasync () => {\n\t\t\t\t// Guards run before the handler: permission → default → operation.\n\t\t\t\tconst permission = execution.definition.permission\n\t\t\t\tif (permission !== undefined) {\n\t\t\t\t\tif (!this.#checkPermission) {\n\t\t\t\t\t\tthrow new CommandPermissionError(execution.operation)\n\t\t\t\t\t}\n\t\t\t\t\tawait this.#checkPermission(execution.context as never, {\n\t\t\t\t\t\tpermission,\n\t\t\t\t\t\toperation: execution.operation,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tawait this.#defaults.guard?.(execution.context as never)\n\t\t\t\tawait execution.definition.guard?.(\n\t\t\t\t\texecution.context as never,\n\t\t\t\t\texecution.command as never,\n\t\t\t\t)\n\t\t\t\tconst result = await execution.run()\n\t\t\t\tconst audit = await execution.definition.audit(\n\t\t\t\t\t{ command: execution.command, result } as never,\n\t\t\t\t\texecution.context as never,\n\t\t\t\t)\n\t\t\t\tif (audit) {\n\t\t\t\t\tconst allowed = execution.definition.aggregates\n\t\t\t\t\tif (allowed && !allowed.includes(audit.aggregate.type)) {\n\t\t\t\t\t\tthrow new CommandAggregateError(\n\t\t\t\t\t\t\texecution.operation,\n\t\t\t\t\t\t\taudit.aggregate.type,\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tawait this.#writeAudit(execution.context as never, {\n\t\t\t\t\t\t...audit,\n\t\t\t\t\t\tclassification: execution.definition.classification,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn result\n\t\t\t},\n\t\t)\n\t}\n}\n\n/** The audit referenced an aggregate type outside the operation's allowlist. */\nclass CommandAggregateError extends Error {\n\treadonly code = 'COMMAND_AGGREGATE_NOT_DECLARED'\n\treadonly name = 'CommandAggregateError'\n\n\tconstructor(operation: string, aggregateType: string) {\n\t\tsuper(\n\t\t\t`Operation \"${operation}\" audited aggregate type \"${aggregateType}\" outside its declared aggregates`,\n\t\t)\n\t}\n}\n\n/** Fails closed: an operation declared a permission but no checker exists. */\nclass CommandPermissionError extends Error {\n\treadonly code = 'COMMAND_PERMISSION_NOT_CONFIGURED'\n\treadonly name = 'CommandPermissionError'\n\n\tconstructor(operation: string) {\n\t\tsuper(\n\t\t\t`Operation \"${operation}\" declares a permission but the Foundation has no checkPermission`,\n\t\t)\n\t}\n}\n\nexport type ApplicationCommand<\n\tContext,\n\tOperations extends AuditedRegistry,\n> = BoundCommand<Context, Operations>\n\ntype CommandConstructor = {\n\tnew <Context, const Operations extends AuditedRegistry>(\n\t\toperations: Operations,\n\t\tdefaults?: CommandDefaults,\n\t): BoundCommand<Context, Operations>\n\toperation: (typeof BoundCommand)['operation']\n}\n\n/**\n * Host facade for a component that owns no application execution capability.\n * Declared once; the sole source of the command protocol, query kernel, and\n * observability: `const { Command, Query, observability } = new Foundation(...)`.\n */\nexport class Foundation<\n\tComponent extends FoundationComponentApi = FoundationComponentApi,\n> {\n\treadonly status: Component['functions']['status']\n\treadonly Command: CommandConstructor\n\treadonly Query = Query\n\treadonly observability: Observability\n\n\tconstructor(component: Component, options: FoundationOptions) {\n\t\tthis.status = component.functions.status\n\t\tconst observability = new Observability(options.observability)\n\t\tthis.observability = observability\n\t\tconst writeAudit = options.observability.writeAudit ?? (() => undefined)\n\t\tconst checkPermission = options.checkPermission\n\t\tthis.Command = class <\n\t\t\tContext,\n\t\t\tconst Operations extends AuditedRegistry,\n\t\t> extends BoundCommand<Context, Operations> {\n\t\t\tconstructor(operations: Operations, defaults?: CommandDefaults) {\n\t\t\t\tsuper(operations, {\n\t\t\t\t\tobservability,\n\t\t\t\t\twriteAudit,\n\t\t\t\t\tcheckPermission,\n\t\t\t\t\tdefaults,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n}\n\nexport type {\n\tCommandExecution,\n\tCommandInput,\n\tCommandRegistry,\n\tCommandResult,\n\tParseable,\n} from './modules/command/command'\nexport { Observability } from './modules/observability/observability'\nexport type {\n\tCommandObservation,\n\tObservabilityOptions,\n} from './modules/observability/observability'\nexport { Query } from './modules/query/query'\nexport type { QueryExecution } from './modules/query/query'\nexport { executeResultBoundary, projectResult } from './result'\nexport type {\n\tResult,\n\tResultBoundary,\n\tTransactionMetricsContext,\n} from './result'\nexport { emitSemanticEvent } from './telemetry'\n\n// Component definition as default export: `import foundation from \"cvx-kit/components/foundation\"` → app.use(foundation)\nexport { default } from \"./convex.config\"\n"],"mappings":"mVAqGA,IAAM,EAAN,KAAsE,CACrE,GACA,GACA,GACA,GACA,GAGA,WAIA,OAAO,UACN,EACa,CACb,OAAO,CACR,CAEA,YACC,EACA,EAMC,CACD,KAAKC,GAAiB,EAAK,cAC3B,KAAKC,GAAc,EAAK,WACxB,KAAKC,GAAmB,EAAK,gBAC7B,KAAKC,GAAY,EAAK,UAAY,CAAC,EACnC,KAAK,WAAa,OAAO,OACxB,OAAO,YACN,OAAO,QAAQ,CAAU,CAAC,CAAC,KAAK,CAAC,EAAW,KAAgB,CAC3D,EACA,EAAW,UACZ,CAAC,CACF,CACD,EACA,KAAKJ,GAAU,IAAIK,EAAwC,CAC1D,aACA,QAAU,GAAc,KAAKC,GAAS,CAAS,CAChD,CAAC,CACF,CAEA,KAAiD,EAM9C,CACF,OAAO,KAAKN,GAAQ,KAAK,CAAQ,CAClC,CAEA,KAAMM,GACL,EAC0C,CAC1C,OAAO,KAAKL,GAAe,QAC1B,CACC,UAAW,EAAU,UACrB,eAAgB,EAAU,WAAW,cACtC,EACA,SAAY,CAEX,IAAM,EAAa,EAAU,WAAW,WACxC,GAAI,IAAe,IAAA,GAAW,CAC7B,GAAI,CAAC,KAAKE,GACT,MAAM,IAAI,EAAuB,EAAU,SAAS,EAErD,MAAM,KAAKA,GAAiB,EAAU,QAAkB,CACvD,aACA,UAAW,EAAU,SACtB,CAAC,CACF,CACA,MAAM,KAAKC,GAAU,QAAQ,EAAU,OAAgB,EACvD,MAAM,EAAU,WAAW,QAC1B,EAAU,QACV,EAAU,OACX,EACA,IAAM,EAAS,MAAM,EAAU,IAAI,EAC7B,EAAQ,MAAM,EAAU,WAAW,MACxC,CAAE,QAAS,EAAU,QAAS,QAAO,EACrC,EAAU,OACX,EACA,GAAI,EAAO,CACV,IAAM,EAAU,EAAU,WAAW,WACrC,GAAI,GAAW,CAAC,EAAQ,SAAS,EAAM,UAAU,IAAI,EACpD,MAAM,IAAI,EACT,EAAU,UACV,EAAM,UAAU,IACjB,EAED,MAAM,KAAKF,GAAY,EAAU,QAAkB,CAClD,GAAG,EACH,eAAgB,EAAU,WAAW,cACtC,CAAC,CACF,CACA,OAAO,CACR,CACD,CACD,CACD,EAGM,EAAN,cAAoC,KAAM,CACzC,KAAgB,iCAChB,KAAgB,wBAEhB,YAAY,EAAmB,EAAuB,CACrD,MACC,cAAc,EAAU,4BAA4B,EAAc,kCACnE,CACD,CACD,EAGM,EAAN,cAAqC,KAAM,CAC1C,KAAgB,oCAChB,KAAgB,yBAEhB,YAAY,EAAmB,CAC9B,MACC,cAAc,EAAU,kEACzB,CACD,CACD,EAoBa,EAAb,KAEE,CACD,OACA,QACA,MAAiB,EACjB,cAEA,YAAY,EAAsB,EAA4B,CAC7D,KAAK,OAAS,EAAU,UAAU,OAClC,IAAM,EAAgB,IAAI,EAAc,EAAQ,aAAa,EAC7D,KAAK,cAAgB,EACrB,IAAM,EAAa,EAAQ,cAAc,iBAAqB,IAAA,IACxD,EAAkB,EAAQ,gBAChC,KAAK,QAAU,cAGL,CAAkC,CAC3C,YAAY,EAAwB,EAA4B,CAC/D,MAAM,EAAY,CACjB,gBACA,aACA,kBACA,UACD,CAAC,CACF,CACD,CACD,CACD"}
|
|
1
|
+
{"version":3,"file":"client.mjs","names":["#kernel","#observability","#writeAudit","#checkPermission","#defaults","CommandKernelClass","#execute"],"sources":["../../../src/components/foundation/client.ts"],"sourcesContent":["import {\n\tCommand as CommandKernelClass,\n\ttype CommandExecution,\n\ttype CommandInput,\n\ttype CommandRegistry,\n\ttype CommandResult,\n} from './modules/command/command'\nimport {\n\tObservability,\n\ttype ObservabilityOptions,\n} from './modules/observability/observability'\nimport { Query } from './modules/query/query'\nimport { executeResultBoundary, projectResult } from './result'\nimport { emitSemanticEvent } from './telemetry'\n\n/** Shape handed to the injected audit writer; classification comes from the operation. */\nexport type AuditEntryInput = {\n\toperation: string\n\tactorId: string\n\taggregate: { type: string; id: string }\n\tmetadata?: Record<string, unknown>\n\tclassification: string\n}\n\nexport type AuditWriter = (\n\tcontext: never,\n\tentry: AuditEntryInput,\n) => Promise<unknown> | unknown\n\ntype MaybePromise<Value> = Value | Promise<Value>\ntype OperationKey<Operations extends CommandRegistry> = Extract<\n\tkeyof Operations,\n\tstring\n>\n\nexport type AuditedOperation = Readonly<{\n\tcommand: Readonly<{ parse: (value: unknown) => unknown }>\n\tresult: Readonly<{ parse: (value: unknown) => unknown }>\n\tclassification: string\n\t/**\n\t * Permission slug required to execute this operation. Checked through the\n\t * Foundation's injected checkPermission BEFORE guards and the handler;\n\t * declaring a permission without injecting a checker fails closed.\n\t */\n\tpermission?: string\n\t/**\n\t * Per-operation precondition, after the permission check and the registry\n\t * default guard, before the handler. Throw to deny — nothing has run yet.\n\t */\n\tguard?: (context: never, command: never) => MaybePromise<void>\n\t/**\n\t * Allowlist of aggregate types this operation's audit may reference. When\n\t * declared, an audit entry whose aggregate.type is not listed throws —\n\t * the audit vocabulary is enforced, not advisory.\n\t */\n\taggregates?: readonly string[]\n\taudit: (\n\t\tresolution: Readonly<{ command: never; result: never }>,\n\t\tcontext: never,\n\t) => MaybePromise<Omit<AuditEntryInput, 'classification'> | null>\n}>\n\n/** Registry-wide defaults applied to every operation of one Command. */\nexport type CommandDefaults = Readonly<{\n\t/** Runs before every operation's own guard. Throw to deny. */\n\tguard?: (context: never) => MaybePromise<void>\n}>\n\n/** Injected permission policy: throw to deny. Host semantics, kit ordering. */\nexport type PermissionChecker = (\n\tcontext: never,\n\tinput: Readonly<{ permission: string; operation: string }>,\n) => MaybePromise<void>\n\nexport type AuditedRegistry = Readonly<Record<string, AuditedOperation>>\n\nexport type FoundationOptions = Readonly<{\n\tobservability: ObservabilityOptions &\n\t\tReadonly<{\n\t\t\t/**\n\t\t\t * In-transaction audit writer used by Command. Operations that\n\t\t\t * return an audit record get it written here automatically.\n\t\t\t */\n\t\t\twriteAudit?: AuditWriter\n\t\t}>\n\t/**\n\t * Permission policy for operations that declare a `permission`. Runs\n\t * before guards and the handler; throw to deny. An operation with a\n\t * permission but no injected checker fails closed.\n\t */\n\tcheckPermission?: PermissionChecker\n}>\n\ntype FoundationComponentApi = Readonly<{\n\tfunctions: Readonly<{ status: unknown }>\n}>\n\n/**\n * Application command protocol bound to a Foundation instance:\n * validate, observe, execute, and audit. `classification` and `audit()`\n * are mandatory per operation — auditing is type-enforced, not opt-in.\n * Obtain it by destructuring the Foundation: `const { Command } = new Foundation(...)`.\n */\nclass BoundCommand<Context, const Operations extends AuditedRegistry> {\n\treadonly #kernel: CommandKernelClass<Context, Operations>\n\treadonly #observability: Observability\n\treadonly #writeAudit: AuditWriter\n\treadonly #checkPermission: PermissionChecker | undefined\n\treadonly #defaults: CommandDefaults\n\n\t/** Declared aggregate types per operation — introspectable for tests. */\n\treadonly aggregates: Readonly<{\n\t\t[Key in OperationKey<Operations>]: Operations[Key]['aggregates']\n\t}>\n\n\tstatic operation<const Definition extends AuditedOperation>(\n\t\tdefinition: Definition,\n\t): Definition {\n\t\treturn definition\n\t}\n\n\tconstructor(\n\t\toperations: Operations,\n\t\tdeps: {\n\t\t\tobservability: Observability\n\t\t\twriteAudit: AuditWriter\n\t\t\tcheckPermission?: PermissionChecker\n\t\t\tdefaults?: CommandDefaults\n\t\t},\n\t) {\n\t\tthis.#observability = deps.observability\n\t\tthis.#writeAudit = deps.writeAudit\n\t\tthis.#checkPermission = deps.checkPermission\n\t\tthis.#defaults = deps.defaults ?? {}\n\t\tthis.aggregates = Object.freeze(\n\t\t\tObject.fromEntries(\n\t\t\t\tObject.entries(operations).map(([operation, definition]) => [\n\t\t\t\t\toperation,\n\t\t\t\t\tdefinition.aggregates,\n\t\t\t\t]),\n\t\t\t),\n\t\t) as this['aggregates']\n\t\tthis.#kernel = new CommandKernelClass<Context, Operations>({\n\t\t\toperations,\n\t\t\texecute: (execution) => this.#execute(execution),\n\t\t})\n\t}\n\n\texec<const Key extends OperationKey<Operations>>(executor: {\n\t\toperation: Key\n\t\thandler: (\n\t\t\tcontext: Context,\n\t\t\tcommand: CommandInput<Operations, Key>,\n\t\t) => MaybePromise<CommandResult<Operations, Key>>\n\t}) {\n\t\treturn this.#kernel.exec(executor)\n\t}\n\n\tasync #execute<Key extends OperationKey<Operations>>(\n\t\texecution: CommandExecution<Context, Operations, Key>,\n\t): Promise<CommandResult<Operations, Key>> {\n\t\treturn this.#observability.observe(\n\t\t\t{\n\t\t\t\toperation: execution.operation,\n\t\t\t\tclassification: execution.definition.classification,\n\t\t\t},\n\t\t\tasync () => {\n\t\t\t\t// Guards run before the handler: permission → default → operation.\n\t\t\t\tconst permission = execution.definition.permission\n\t\t\t\tif (permission !== undefined) {\n\t\t\t\t\tif (!this.#checkPermission) {\n\t\t\t\t\t\tthrow new CommandPermissionError(execution.operation)\n\t\t\t\t\t}\n\t\t\t\t\tawait this.#checkPermission(execution.context as never, {\n\t\t\t\t\t\tpermission,\n\t\t\t\t\t\toperation: execution.operation,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tawait this.#defaults.guard?.(execution.context as never)\n\t\t\t\tawait execution.definition.guard?.(\n\t\t\t\t\texecution.context as never,\n\t\t\t\t\texecution.command as never,\n\t\t\t\t)\n\t\t\t\tconst result = await execution.run()\n\t\t\t\tconst audit = await execution.definition.audit(\n\t\t\t\t\t{ command: execution.command, result } as never,\n\t\t\t\t\texecution.context as never,\n\t\t\t\t)\n\t\t\t\tif (audit) {\n\t\t\t\t\tconst allowed = execution.definition.aggregates\n\t\t\t\t\tif (allowed && !allowed.includes(audit.aggregate.type)) {\n\t\t\t\t\t\tthrow new CommandAggregateError(\n\t\t\t\t\t\t\texecution.operation,\n\t\t\t\t\t\t\taudit.aggregate.type,\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tawait this.#writeAudit(execution.context as never, {\n\t\t\t\t\t\t...audit,\n\t\t\t\t\t\tclassification: execution.definition.classification,\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\treturn result\n\t\t\t},\n\t\t)\n\t}\n}\n\n/** The audit referenced an aggregate type outside the operation's allowlist. */\nclass CommandAggregateError extends Error {\n\treadonly code = 'COMMAND_AGGREGATE_NOT_DECLARED'\n\treadonly name = 'CommandAggregateError'\n\n\tconstructor(operation: string, aggregateType: string) {\n\t\tsuper(\n\t\t\t`Operation \"${operation}\" audited aggregate type \"${aggregateType}\" outside its declared aggregates`,\n\t\t)\n\t}\n}\n\n/** Fails closed: an operation declared a permission but no checker exists. */\nclass CommandPermissionError extends Error {\n\treadonly code = 'COMMAND_PERMISSION_NOT_CONFIGURED'\n\treadonly name = 'CommandPermissionError'\n\n\tconstructor(operation: string) {\n\t\tsuper(\n\t\t\t`Operation \"${operation}\" declares a permission but the Foundation has no checkPermission`,\n\t\t)\n\t}\n}\n\nexport type ApplicationCommand<\n\tContext,\n\tOperations extends AuditedRegistry,\n> = BoundCommand<Context, Operations>\n\ntype CommandConstructor = {\n\tnew <Context, const Operations extends AuditedRegistry>(\n\t\toperations: Operations,\n\t\tdefaults?: CommandDefaults,\n\t): BoundCommand<Context, Operations>\n\toperation: (typeof BoundCommand)['operation']\n}\n\n/**\n * Host facade for a component that owns no application execution capability.\n * Declared once; the sole source of the command protocol, query kernel, and\n * observability: `const { Command, Query, observability } = new Foundation(...)`.\n */\nexport class Foundation<\n\tComponent extends FoundationComponentApi = FoundationComponentApi,\n> {\n\treadonly status: Component['functions']['status']\n\treadonly Command: CommandConstructor\n\treadonly Query = Query\n\treadonly observability: Observability\n\t/** Typed-failure boundary — see result.ts. Facade-bound like the kernels. */\n\treadonly executeResultBoundary = executeResultBoundary\n\treadonly projectResult = projectResult\n\treadonly emitSemanticEvent = emitSemanticEvent\n\n\tconstructor(component: Component, options: FoundationOptions) {\n\t\tthis.status = component.functions.status\n\t\tconst observability = new Observability(options.observability)\n\t\tthis.observability = observability\n\t\tconst writeAudit = options.observability.writeAudit ?? (() => undefined)\n\t\tconst checkPermission = options.checkPermission\n\t\tthis.Command = class <\n\t\t\tContext,\n\t\t\tconst Operations extends AuditedRegistry,\n\t\t> extends BoundCommand<Context, Operations> {\n\t\t\tconstructor(operations: Operations, defaults?: CommandDefaults) {\n\t\t\t\tsuper(operations, {\n\t\t\t\t\tobservability,\n\t\t\t\t\twriteAudit,\n\t\t\t\t\tcheckPermission,\n\t\t\t\t\tdefaults,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n}\n\n// Kernel CLASSES are deliberately NOT exported: Command, Query, and\n// Observability exist only as capabilities of a Foundation instance —\n// `const { Command, Query, observability } = new Foundation(...)`. A loose\n// import would construct kernels without the injected observability, audit\n// writer, and permission checker. Types stay exported for signatures.\nexport type {\n\tCommandExecution,\n\tCommandInput,\n\tCommandRegistry,\n\tCommandResult,\n\tParseable,\n} from './modules/command/command'\nexport type {\n\tCommandObservation,\n\tObservabilityOptions,\n} from './modules/observability/observability'\nexport type { QueryExecution } from './modules/query/query'\nexport type {\n\tResult,\n\tResultBoundary,\n\tTransactionMetricsContext,\n} from './result'\n\n// Component definition as default export: `import foundation from \"cvx-kit/components/foundation\"` → app.use(foundation)\nexport { default } from \"./convex.config\"\n"],"mappings":"mVAuGA,IAAM,EAAN,KAAsE,CACrE,GACA,GACA,GACA,GACA,GAGA,WAIA,OAAO,UACN,EACa,CACb,OAAO,CACR,CAEA,YACC,EACA,EAMC,CACD,KAAKC,GAAiB,EAAK,cAC3B,KAAKC,GAAc,EAAK,WACxB,KAAKC,GAAmB,EAAK,gBAC7B,KAAKC,GAAY,EAAK,UAAY,CAAC,EACnC,KAAK,WAAa,OAAO,OACxB,OAAO,YACN,OAAO,QAAQ,CAAU,CAAC,CAAC,KAAK,CAAC,EAAW,KAAgB,CAC3D,EACA,EAAW,UACZ,CAAC,CACF,CACD,EACA,KAAKJ,GAAU,IAAIK,EAAwC,CAC1D,aACA,QAAU,GAAc,KAAKC,GAAS,CAAS,CAChD,CAAC,CACF,CAEA,KAAiD,EAM9C,CACF,OAAO,KAAKN,GAAQ,KAAK,CAAQ,CAClC,CAEA,KAAMM,GACL,EAC0C,CAC1C,OAAO,KAAKL,GAAe,QAC1B,CACC,UAAW,EAAU,UACrB,eAAgB,EAAU,WAAW,cACtC,EACA,SAAY,CAEX,IAAM,EAAa,EAAU,WAAW,WACxC,GAAI,IAAe,IAAA,GAAW,CAC7B,GAAI,CAAC,KAAKE,GACT,MAAM,IAAI,EAAuB,EAAU,SAAS,EAErD,MAAM,KAAKA,GAAiB,EAAU,QAAkB,CACvD,aACA,UAAW,EAAU,SACtB,CAAC,CACF,CACA,MAAM,KAAKC,GAAU,QAAQ,EAAU,OAAgB,EACvD,MAAM,EAAU,WAAW,QAC1B,EAAU,QACV,EAAU,OACX,EACA,IAAM,EAAS,MAAM,EAAU,IAAI,EAC7B,EAAQ,MAAM,EAAU,WAAW,MACxC,CAAE,QAAS,EAAU,QAAS,QAAO,EACrC,EAAU,OACX,EACA,GAAI,EAAO,CACV,IAAM,EAAU,EAAU,WAAW,WACrC,GAAI,GAAW,CAAC,EAAQ,SAAS,EAAM,UAAU,IAAI,EACpD,MAAM,IAAI,EACT,EAAU,UACV,EAAM,UAAU,IACjB,EAED,MAAM,KAAKF,GAAY,EAAU,QAAkB,CAClD,GAAG,EACH,eAAgB,EAAU,WAAW,cACtC,CAAC,CACF,CACA,OAAO,CACR,CACD,CACD,CACD,EAGM,EAAN,cAAoC,KAAM,CACzC,KAAgB,iCAChB,KAAgB,wBAEhB,YAAY,EAAmB,EAAuB,CACrD,MACC,cAAc,EAAU,4BAA4B,EAAc,kCACnE,CACD,CACD,EAGM,EAAN,cAAqC,KAAM,CAC1C,KAAgB,oCAChB,KAAgB,yBAEhB,YAAY,EAAmB,CAC9B,MACC,cAAc,EAAU,kEACzB,CACD,CACD,EAoBa,EAAb,KAEE,CACD,OACA,QACA,MAAiB,EACjB,cAEA,sBAAiC,EACjC,cAAyB,EACzB,kBAA6B,EAE7B,YAAY,EAAsB,EAA4B,CAC7D,KAAK,OAAS,EAAU,UAAU,OAClC,IAAM,EAAgB,IAAI,EAAc,EAAQ,aAAa,EAC7D,KAAK,cAAgB,EACrB,IAAM,EAAa,EAAQ,cAAc,iBAAqB,IAAA,IACxD,EAAkB,EAAQ,gBAChC,KAAK,QAAU,cAGL,CAAkC,CAC3C,YAAY,EAAwB,EAA4B,CAC/D,MAAM,EAAY,CACjB,gBACA,aACA,kBACA,UACD,CAAC,CACF,CACD,CACD,CACD"}
|
package/docs/commands.md
CHANGED
|
@@ -184,7 +184,8 @@ failure. Default sink is one JSON line on `console.info`
|
|
|
184
184
|
## `executeResultBoundary` — typed failures, but only while it's safe
|
|
185
185
|
|
|
186
186
|
```ts
|
|
187
|
-
|
|
187
|
+
// destructured from the Foundation facade, like everything else
|
|
188
|
+
const { executeResultBoundary, projectResult } = new Foundation(...)
|
|
188
189
|
|
|
189
190
|
const result = await executeResultBoundary(ctx, () => run(), boundary)
|
|
190
191
|
// Result<Value, Failure> = { ok: true, value } | { ok: false, error }
|
package/docs/llms.md
CHANGED
|
@@ -69,7 +69,7 @@ function constructors, a trigger registry, an audited command protocol
|
|
|
69
69
|
| `cvx-kit/tenancy` | `createTenantRules`, `composeRules`, `requireTenantReference`, `assertTenantOwned`, `TENANT_FIELD` |
|
|
70
70
|
| `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `tenantOwnership`, `Triggers` |
|
|
71
71
|
| `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
|
|
72
|
-
| `cvx-kit/components/foundation` | `Foundation
|
|
72
|
+
| `cvx-kit/components/foundation` | `Foundation` — the ONLY runtime export besides the default component config for `app.use`. Everything (`Command`, `Query`, `observability`, `executeResultBoundary`, `projectResult`, `emitSemanticEvent`) destructures from the instance; nothing is importable loose. |
|
|
73
73
|
| `cvx-kit/components/approvals` | `Approvals` client; default export = component config for `app.use` |
|
|
74
74
|
| `cvx-kit/test` | `registerFoundation(t)`, `registerApprovals(t)` for convex-test |
|
|
75
75
|
|
package/package.json
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cvx-kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Reusable Convex application kit: foundation kernels, approvals component, zod table boundaries, WorkOS-backed auth function constructors, and audited command protocol.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"sideEffects": false,
|
|
8
|
-
"files": [
|
|
9
|
-
"dist",
|
|
10
|
-
"src",
|
|
11
|
-
"!src/docs",
|
|
12
|
-
"docs"
|
|
13
|
-
],
|
|
14
8
|
"exports": {
|
|
15
9
|
"./package.json": "./package.json",
|
|
16
10
|
".": {
|
|
@@ -74,7 +68,7 @@
|
|
|
74
68
|
"typecheck": "tsc --noEmit",
|
|
75
69
|
"test": "vp test run",
|
|
76
70
|
"docs:sync": "rm -f docs/*.md && cp src/docs/*.md docs/",
|
|
77
|
-
"prepublishOnly": "bun run
|
|
71
|
+
"prepublishOnly": "bun run build && bun run typecheck && bun run test && bun run docs:sync",
|
|
78
72
|
"build:codegen": "convex codegen --component-dir ./src/components/foundation && convex codegen --component-dir ./src/components/approvals && vp pack",
|
|
79
73
|
"release": "npm version patch && git push --follow-tags",
|
|
80
74
|
"release:minor": "npm version minor && git push --follow-tags"
|
package/src/test.ts
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
* Test helpers for consumers, following the official component template:
|
|
3
3
|
* register the kit's components on a convex-test instance so host-app tests
|
|
4
4
|
* run against them in-memory. Requires a vitest-compatible runner
|
|
5
|
-
* (import.meta.glob), which is why this
|
|
5
|
+
* (import.meta.glob), which is why this file ships as source — but it
|
|
6
|
+
* registers the COMPILED component modules from dist, so dist is the only
|
|
7
|
+
* code the package ships.
|
|
6
8
|
*/
|
|
7
|
-
import approvalsSchema from '
|
|
8
|
-
import foundationSchema from '
|
|
9
|
+
import approvalsSchema from '../dist/components/approvals/schema.mjs'
|
|
10
|
+
import foundationSchema from '../dist/components/foundation/schema.mjs'
|
|
9
11
|
|
|
10
12
|
const foundationModules = import.meta.glob(
|
|
11
|
-
'
|
|
13
|
+
'../dist/components/foundation/**/*.mjs',
|
|
12
14
|
)
|
|
13
15
|
const approvalsModules = import.meta.glob(
|
|
14
|
-
'
|
|
16
|
+
'../dist/components/approvals/**/*.mjs',
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
type RegistersComponents = {
|
package/src/auth.ts
DELETED
|
@@ -1,473 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
zCustomAction,
|
|
3
|
-
zCustomMutation,
|
|
4
|
-
zCustomQuery,
|
|
5
|
-
} from 'convex-helpers/server/zod4'
|
|
6
|
-
import type {
|
|
7
|
-
ActionBuilder,
|
|
8
|
-
DocumentByInfo,
|
|
9
|
-
GenericActionCtx,
|
|
10
|
-
GenericDataModel,
|
|
11
|
-
GenericMutationCtx,
|
|
12
|
-
GenericQueryCtx,
|
|
13
|
-
GenericTableInfo,
|
|
14
|
-
IndexNames,
|
|
15
|
-
IndexRange,
|
|
16
|
-
IndexRangeBuilder,
|
|
17
|
-
MutationBuilder,
|
|
18
|
-
NamedIndex,
|
|
19
|
-
Query,
|
|
20
|
-
QueryBuilder,
|
|
21
|
-
QueryInitializer,
|
|
22
|
-
UserIdentity,
|
|
23
|
-
} from 'convex/server'
|
|
24
|
-
import type { Triggers } from 'convex-helpers/server/triggers'
|
|
25
|
-
import type { TableNamesInDataModel } from 'convex/server'
|
|
26
|
-
import { defaultErrors, type ErrorFactory } from './errors'
|
|
27
|
-
import {
|
|
28
|
-
composeRules,
|
|
29
|
-
createTenantRules,
|
|
30
|
-
wrapDatabaseReader,
|
|
31
|
-
wrapDatabaseWriter,
|
|
32
|
-
type RLSConfig,
|
|
33
|
-
type Rules,
|
|
34
|
-
} from './tenancy'
|
|
35
|
-
|
|
36
|
-
/** The default vocabulary; every app can supply its own via the Role generic. */
|
|
37
|
-
export type DefaultRole = 'reader' | 'writer' | 'admin'
|
|
38
|
-
|
|
39
|
-
export type Actor<Role extends string> = Readonly<{
|
|
40
|
-
userId: string
|
|
41
|
-
organizationId: string
|
|
42
|
-
role: Role
|
|
43
|
-
}>
|
|
44
|
-
|
|
45
|
-
export type AuthBundle<Role extends string> = Readonly<{
|
|
46
|
-
identity: UserIdentity
|
|
47
|
-
user: Readonly<{ id: string }>
|
|
48
|
-
org: Readonly<{ organizationId: string; role: Role }>
|
|
49
|
-
role: Role
|
|
50
|
-
actor: Actor<Role>
|
|
51
|
-
/** The resolved tenant — security.tenancy.resolve, or the organization id. */
|
|
52
|
-
tenant: string
|
|
53
|
-
}>
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Row-level security configuration. Tenant isolation and role-level rules
|
|
57
|
-
* compose (AND) onto the same wrapped database: queries get a wrapped
|
|
58
|
-
* reader, mutations get triggers first, then a wrapped writer.
|
|
59
|
-
*/
|
|
60
|
-
export type SecurityConfig<
|
|
61
|
-
DataModel extends GenericDataModel,
|
|
62
|
-
Role extends string,
|
|
63
|
-
> = {
|
|
64
|
-
/**
|
|
65
|
-
* Tenant isolation. `tables` is THE registry: every listed table gets
|
|
66
|
-
* read/insert/modify gated on row.tenant === ctx.tenant. Pair with
|
|
67
|
-
* tenantTable (schema) and tenantOwnership (triggers) on the same list.
|
|
68
|
-
*/
|
|
69
|
-
tenancy?: {
|
|
70
|
-
tables: readonly TableNamesInDataModel<DataModel>[]
|
|
71
|
-
/** Maps the auth bundle to the tenant; defaults to org.organizationId. */
|
|
72
|
-
resolve?: (bundle: Omit<AuthBundle<Role>, 'tenant'>) => string
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Role-level (or any policy) rules derived from the authenticated bundle,
|
|
76
|
-
* composed AND-wise with tenant isolation. E.g. gate `modify` on a table
|
|
77
|
-
* to admin roles.
|
|
78
|
-
*/
|
|
79
|
-
rules?: (bundle: AuthBundle<Role>) => Rules<unknown, DataModel>
|
|
80
|
-
/**
|
|
81
|
-
* Policy for tables with no rule. Defaults to 'deny' when tenancy is
|
|
82
|
-
* configured (unlisted tables are unreachable — fail closed), 'allow'
|
|
83
|
-
* otherwise.
|
|
84
|
-
*/
|
|
85
|
-
defaultPolicy?: RLSConfig['defaultPolicy']
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
type AnyAuthContext<DataModel extends GenericDataModel> =
|
|
89
|
-
| GenericQueryCtx<DataModel>
|
|
90
|
-
| GenericMutationCtx<DataModel>
|
|
91
|
-
| GenericActionCtx<DataModel>
|
|
92
|
-
|
|
93
|
-
export type AuthFunctionsConfig<
|
|
94
|
-
DataModel extends GenericDataModel,
|
|
95
|
-
Role extends string,
|
|
96
|
-
> = {
|
|
97
|
-
/** The consumer's generated function builders (from ./_generated/server). */
|
|
98
|
-
query: QueryBuilder<DataModel, 'public'>
|
|
99
|
-
mutation: MutationBuilder<DataModel, 'public'>
|
|
100
|
-
action: ActionBuilder<DataModel, 'public'>
|
|
101
|
-
internalMutation: MutationBuilder<DataModel, 'internal'>
|
|
102
|
-
internalAction: ActionBuilder<DataModel, 'internal'>
|
|
103
|
-
/** Resolves the synchronized principal, e.g. (ctx) => authKit.getAuthUser(ctx). */
|
|
104
|
-
getAuthUser: (
|
|
105
|
-
ctx: AnyAuthContext<DataModel>,
|
|
106
|
-
) => Promise<{ id: string } | null>
|
|
107
|
-
/**
|
|
108
|
-
* Maps an identity role slug onto the app's role vocabulary. Returning
|
|
109
|
-
* null rejects the caller with FORBIDDEN — unknown roles never pass.
|
|
110
|
-
*/
|
|
111
|
-
mapRole: (roleSlug?: string) => Role | null
|
|
112
|
-
/** Roles allowed through the admin* constructors. */
|
|
113
|
-
adminRoles: readonly NoInfer<Role>[]
|
|
114
|
-
/**
|
|
115
|
-
* Live membership check for actions (queries/mutations trust the JWT).
|
|
116
|
-
* Authentication fails closed with FORBIDDEN when verification errors.
|
|
117
|
-
* Omit to let actions trust the JWT like queries do.
|
|
118
|
-
*/
|
|
119
|
-
verifyMembership?: (input: {
|
|
120
|
-
userId: string
|
|
121
|
-
organizationId: string
|
|
122
|
-
}) => Promise<{ organizationId: string; roleSlug: string } | null>
|
|
123
|
-
/** Host error policy; defaults to throwing KitError. */
|
|
124
|
-
errors?: ErrorFactory
|
|
125
|
-
/**
|
|
126
|
-
* Application trigger registry. When set, every authMutation,
|
|
127
|
-
* roleMutation, and systemMutation write runs through triggers.wrapDB —
|
|
128
|
-
* evaluations registered on tables fire structurally.
|
|
129
|
-
*/
|
|
130
|
-
triggers?: Triggers<DataModel>
|
|
131
|
-
/** Lower-level alternative to `triggers`: a custom ctx wrapper. */
|
|
132
|
-
wrapDB?: (
|
|
133
|
-
ctx: GenericMutationCtx<DataModel>,
|
|
134
|
-
) => GenericMutationCtx<DataModel>
|
|
135
|
-
/** Upper bound for include() reads. */
|
|
136
|
-
maxIncludedQueryRows?: number
|
|
137
|
-
/**
|
|
138
|
-
* Row-level security: tenant isolation (from a table registry) and
|
|
139
|
-
* role-level rules, applied structurally inside every auth*, role*, and
|
|
140
|
-
* admin* constructor. system* constructors stay unwrapped (trusted).
|
|
141
|
-
*/
|
|
142
|
-
security?: SecurityConfig<DataModel, Role>
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** WorkOS-style default: `member` writes; reader/writer/admin pass through. */
|
|
146
|
-
export function defaultRoleMap(roleSlug?: string): DefaultRole | null {
|
|
147
|
-
const normalized = roleSlug?.trim().toLowerCase()
|
|
148
|
-
if (normalized === 'member') return 'writer'
|
|
149
|
-
return normalized === 'reader' ||
|
|
150
|
-
normalized === 'writer' ||
|
|
151
|
-
normalized === 'admin'
|
|
152
|
-
? normalized
|
|
153
|
-
: null
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export type IncludedQuery<Table extends GenericTableInfo> = {
|
|
157
|
-
when<Value>(
|
|
158
|
-
value: Value | null | undefined,
|
|
159
|
-
select: (query: QueryInitializer<Table>, value: Value) => Query<Table>,
|
|
160
|
-
): IncludedQuery<Table>
|
|
161
|
-
matching<IndexName extends IndexNames<Table>>(
|
|
162
|
-
indexName: IndexName,
|
|
163
|
-
indexRange?: (
|
|
164
|
-
query: IndexRangeBuilder<
|
|
165
|
-
DocumentByInfo<Table>,
|
|
166
|
-
NamedIndex<Table, IndexName>
|
|
167
|
-
>,
|
|
168
|
-
) => IndexRange,
|
|
169
|
-
shouldMatch?: boolean,
|
|
170
|
-
): IncludedQuery<Table>
|
|
171
|
-
otherwise(
|
|
172
|
-
select: (query: QueryInitializer<Table>) => Query<Table>,
|
|
173
|
-
): Query<Table>
|
|
174
|
-
resolve(): Query<Table>
|
|
175
|
-
execute<Result = Awaited<ReturnType<Query<Table>['take']>>>(
|
|
176
|
-
limit: number,
|
|
177
|
-
transform?: (
|
|
178
|
-
data: Awaited<ReturnType<Query<Table>['take']>>,
|
|
179
|
-
) => Result | Promise<Result>,
|
|
180
|
-
): Promise<Result>
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export type Include = <Table extends GenericTableInfo>(
|
|
184
|
-
query: QueryInitializer<Table>,
|
|
185
|
-
) => IncludedQuery<Table>
|
|
186
|
-
|
|
187
|
-
/** Builds an include() that selects the first matching indexed query and bounds the read. */
|
|
188
|
-
export function createInclude(options?: {
|
|
189
|
-
errors?: ErrorFactory
|
|
190
|
-
maxRows?: number
|
|
191
|
-
}): Include {
|
|
192
|
-
const errors = options?.errors ?? defaultErrors
|
|
193
|
-
const maxRows = options?.maxRows ?? 100
|
|
194
|
-
|
|
195
|
-
function attach<Table extends GenericTableInfo>(
|
|
196
|
-
query: QueryInitializer<Table>,
|
|
197
|
-
selected?: Query<Table>,
|
|
198
|
-
): IncludedQuery<Table> {
|
|
199
|
-
return {
|
|
200
|
-
when: (value, select) =>
|
|
201
|
-
selected || value === undefined || value === null
|
|
202
|
-
? attach(query, selected)
|
|
203
|
-
: attach(query, select(query, value)),
|
|
204
|
-
matching: (indexName, indexRange, shouldMatch = true) =>
|
|
205
|
-
selected || !shouldMatch
|
|
206
|
-
? attach(query, selected)
|
|
207
|
-
: attach(query, query.withIndex(indexName, indexRange)),
|
|
208
|
-
otherwise: (select) => selected ?? select(query),
|
|
209
|
-
resolve: () => selected ?? query.fullTableScan(),
|
|
210
|
-
execute: async (limit, transform) => {
|
|
211
|
-
if (!Number.isSafeInteger(limit) || limit < 1 || limit > maxRows) {
|
|
212
|
-
return errors.throw({
|
|
213
|
-
code: 'INVALID_REQUEST_BOUNDARY',
|
|
214
|
-
message: `A bounded query limit must be from 1 to ${maxRows}`,
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
const data = await (selected ?? query.fullTableScan()).take(limit)
|
|
218
|
-
return transform ? transform(data) : (data as never)
|
|
219
|
-
},
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return (query) => attach(query)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Builds the auth-aware function constructors from the host's injected
|
|
228
|
-
* policy: identity resolution, the app's own role vocabulary, live
|
|
229
|
-
* membership verification for actions, trigger wrapping for mutations,
|
|
230
|
-
* and errors. Role-based access is first-class — every constructor family
|
|
231
|
-
* has a role-gated variant (roleQuery/roleMutation/roleAction), and the
|
|
232
|
-
* admin* constructors are just that gate applied to config.adminRoles.
|
|
233
|
-
*/
|
|
234
|
-
export function createAuthFunctions<
|
|
235
|
-
DataModel extends GenericDataModel,
|
|
236
|
-
const Role extends string = DefaultRole,
|
|
237
|
-
>(config: AuthFunctionsConfig<DataModel, Role>) {
|
|
238
|
-
const errors = config.errors ?? defaultErrors
|
|
239
|
-
const wrapDB = config.triggers
|
|
240
|
-
? (ctx: GenericMutationCtx<DataModel>) =>
|
|
241
|
-
(config.triggers as Triggers<DataModel>).wrapDB(ctx)
|
|
242
|
-
: (config.wrapDB ?? ((ctx) => ctx))
|
|
243
|
-
const include = createInclude({
|
|
244
|
-
errors,
|
|
245
|
-
maxRows: config.maxIncludedQueryRows,
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
async function authenticatedUser(
|
|
249
|
-
ctx: AnyAuthContext<DataModel>,
|
|
250
|
-
): Promise<AuthBundle<Role>> {
|
|
251
|
-
const identity = await ctx.auth.getUserIdentity()
|
|
252
|
-
const user = identity ? await config.getAuthUser(ctx) : null
|
|
253
|
-
if (!identity || !user) return errors.throw({ code: 'UNAUTHENTICATED' })
|
|
254
|
-
const organization = identity.organization
|
|
255
|
-
const organizationId =
|
|
256
|
-
organization &&
|
|
257
|
-
typeof organization === 'object' &&
|
|
258
|
-
'organizationId' in organization &&
|
|
259
|
-
typeof organization.organizationId === 'string'
|
|
260
|
-
? organization.organizationId
|
|
261
|
-
: typeof identity.org_id === 'string'
|
|
262
|
-
? identity.org_id
|
|
263
|
-
: null
|
|
264
|
-
if (!organizationId) return errors.throw({ code: 'UNAUTHENTICATED' })
|
|
265
|
-
const organizationRole =
|
|
266
|
-
organization &&
|
|
267
|
-
typeof organization === 'object' &&
|
|
268
|
-
'role' in organization &&
|
|
269
|
-
typeof organization.role === 'string'
|
|
270
|
-
? organization.role
|
|
271
|
-
: typeof identity.role === 'string'
|
|
272
|
-
? identity.role
|
|
273
|
-
: undefined
|
|
274
|
-
const role = config.mapRole(organizationRole)
|
|
275
|
-
if (!role) return errors.throw({ code: 'FORBIDDEN' })
|
|
276
|
-
const base = {
|
|
277
|
-
identity,
|
|
278
|
-
user: Object.freeze({ id: user.id }),
|
|
279
|
-
org: Object.freeze({ organizationId, role }),
|
|
280
|
-
role,
|
|
281
|
-
actor: Object.freeze({ userId: user.id, organizationId, role }),
|
|
282
|
-
}
|
|
283
|
-
return Object.freeze({
|
|
284
|
-
...base,
|
|
285
|
-
tenant: config.security?.tenancy?.resolve?.(base) ?? organizationId,
|
|
286
|
-
})
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const securityConfig: RLSConfig | undefined = config.security
|
|
290
|
-
? {
|
|
291
|
-
defaultPolicy:
|
|
292
|
-
config.security.defaultPolicy ??
|
|
293
|
-
(config.security.tenancy ? 'deny' : 'allow'),
|
|
294
|
-
}
|
|
295
|
-
: undefined
|
|
296
|
-
|
|
297
|
-
function securityRules(
|
|
298
|
-
bundle: AuthBundle<Role>,
|
|
299
|
-
): Rules<unknown, DataModel> | undefined {
|
|
300
|
-
const security = config.security
|
|
301
|
-
if (!security) return undefined
|
|
302
|
-
const sets: Rules<unknown, DataModel>[] = []
|
|
303
|
-
if (security.tenancy) {
|
|
304
|
-
sets.push(
|
|
305
|
-
createTenantRules<DataModel>(bundle.tenant, security.tenancy.tables),
|
|
306
|
-
)
|
|
307
|
-
}
|
|
308
|
-
if (security.rules) sets.push(security.rules(bundle))
|
|
309
|
-
return sets.length === 1 ? sets[0] : composeRules(...sets)
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function secureReader(
|
|
313
|
-
ctx: GenericQueryCtx<DataModel>,
|
|
314
|
-
bundle: AuthBundle<Role>,
|
|
315
|
-
): GenericQueryCtx<DataModel> {
|
|
316
|
-
const rules = securityRules(bundle)
|
|
317
|
-
if (!rules) return ctx
|
|
318
|
-
return {
|
|
319
|
-
...ctx,
|
|
320
|
-
db: wrapDatabaseReader({}, ctx.db, rules as never, securityConfig),
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function secureWriter(
|
|
325
|
-
ctx: GenericMutationCtx<DataModel>,
|
|
326
|
-
bundle: AuthBundle<Role>,
|
|
327
|
-
): GenericMutationCtx<DataModel> {
|
|
328
|
-
const rules = securityRules(bundle)
|
|
329
|
-
if (!rules) return ctx
|
|
330
|
-
// Triggers wrap first (ctx already trigger-wrapped by the caller),
|
|
331
|
-
// then row-level security wraps the triggered db — order matters.
|
|
332
|
-
return {
|
|
333
|
-
...ctx,
|
|
334
|
-
db: wrapDatabaseWriter({}, ctx.db, rules as never, securityConfig),
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async function verifiedMembership(userId: string, organizationId: string) {
|
|
339
|
-
if (!config.verifyMembership) return null
|
|
340
|
-
try {
|
|
341
|
-
const membership = await config.verifyMembership({
|
|
342
|
-
userId,
|
|
343
|
-
organizationId,
|
|
344
|
-
})
|
|
345
|
-
if (membership && membership.organizationId === organizationId) {
|
|
346
|
-
return membership
|
|
347
|
-
}
|
|
348
|
-
} catch {
|
|
349
|
-
// Authentication fails closed when membership cannot be verified.
|
|
350
|
-
}
|
|
351
|
-
return errors.throw({ code: 'FORBIDDEN' })
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function requireRole(role: Role, allowed: readonly Role[]) {
|
|
355
|
-
if (!allowed.includes(role)) return errors.throw({ code: 'FORBIDDEN' })
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async function queryInput(ctx: GenericQueryCtx<DataModel>) {
|
|
359
|
-
const bundle = await authenticatedUser(ctx)
|
|
360
|
-
return {
|
|
361
|
-
ctx: { ...secureReader(ctx, bundle), include, ...bundle },
|
|
362
|
-
args: {},
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
async function mutationInput(ctx: GenericMutationCtx<DataModel>) {
|
|
367
|
-
const bundle = await authenticatedUser(ctx)
|
|
368
|
-
return {
|
|
369
|
-
ctx: { ...secureWriter(wrapDB(ctx), bundle), include, ...bundle },
|
|
370
|
-
args: {},
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function actionInput(ctx: GenericActionCtx<DataModel>) {
|
|
375
|
-
const authenticated = await authenticatedUser(ctx)
|
|
376
|
-
const membership = await verifiedMembership(
|
|
377
|
-
authenticated.user.id,
|
|
378
|
-
authenticated.org.organizationId,
|
|
379
|
-
)
|
|
380
|
-
if (!membership) return { ctx: { ...ctx, ...authenticated }, args: {} }
|
|
381
|
-
const role = config.mapRole(membership.roleSlug)
|
|
382
|
-
if (!role) return errors.throw({ code: 'FORBIDDEN' })
|
|
383
|
-
const verified = {
|
|
384
|
-
identity: authenticated.identity,
|
|
385
|
-
user: authenticated.user,
|
|
386
|
-
org: Object.freeze({
|
|
387
|
-
organizationId: membership.organizationId,
|
|
388
|
-
role,
|
|
389
|
-
}),
|
|
390
|
-
role,
|
|
391
|
-
actor: Object.freeze({
|
|
392
|
-
userId: authenticated.user.id,
|
|
393
|
-
organizationId: membership.organizationId,
|
|
394
|
-
role,
|
|
395
|
-
}),
|
|
396
|
-
}
|
|
397
|
-
return {
|
|
398
|
-
ctx: {
|
|
399
|
-
...ctx,
|
|
400
|
-
...verified,
|
|
401
|
-
// Tenant re-derives from the live-verified organization.
|
|
402
|
-
tenant:
|
|
403
|
-
config.security?.tenancy?.resolve?.(verified) ??
|
|
404
|
-
membership.organizationId,
|
|
405
|
-
},
|
|
406
|
-
args: {},
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** Query constructor allowing only the given roles. */
|
|
411
|
-
function roleQuery(...allowed: readonly Role[]) {
|
|
412
|
-
return zCustomQuery(config.query, {
|
|
413
|
-
args: {},
|
|
414
|
-
input: async (ctx) => {
|
|
415
|
-
const authenticated = await queryInput(ctx)
|
|
416
|
-
requireRole(authenticated.ctx.actor.role, allowed)
|
|
417
|
-
return authenticated
|
|
418
|
-
},
|
|
419
|
-
})
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/** Mutation constructor allowing only the given roles. */
|
|
423
|
-
function roleMutation(...allowed: readonly Role[]) {
|
|
424
|
-
return zCustomMutation(config.mutation, {
|
|
425
|
-
args: {},
|
|
426
|
-
input: async (ctx) => {
|
|
427
|
-
const authenticated = await mutationInput(ctx)
|
|
428
|
-
requireRole(authenticated.ctx.actor.role, allowed)
|
|
429
|
-
return authenticated
|
|
430
|
-
},
|
|
431
|
-
})
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/** Action constructor allowing only the given roles (post live verification). */
|
|
435
|
-
function roleAction(...allowed: readonly Role[]) {
|
|
436
|
-
return zCustomAction(config.action, {
|
|
437
|
-
args: {},
|
|
438
|
-
input: async (ctx) => {
|
|
439
|
-
const authenticated = await actionInput(ctx)
|
|
440
|
-
requireRole(authenticated.ctx.actor.role, allowed)
|
|
441
|
-
return authenticated
|
|
442
|
-
},
|
|
443
|
-
})
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
return {
|
|
447
|
-
include,
|
|
448
|
-
authenticatedUser,
|
|
449
|
-
roleQuery,
|
|
450
|
-
roleMutation,
|
|
451
|
-
roleAction,
|
|
452
|
-
authQuery: zCustomQuery(config.query, { args: {}, input: queryInput }),
|
|
453
|
-
authMutation: zCustomMutation(config.mutation, {
|
|
454
|
-
args: {},
|
|
455
|
-
input: mutationInput,
|
|
456
|
-
}),
|
|
457
|
-
authAction: zCustomAction(config.action, {
|
|
458
|
-
args: {},
|
|
459
|
-
input: actionInput,
|
|
460
|
-
}),
|
|
461
|
-
adminQuery: roleQuery(...config.adminRoles),
|
|
462
|
-
adminMutation: roleMutation(...config.adminRoles),
|
|
463
|
-
adminAction: roleAction(...config.adminRoles),
|
|
464
|
-
systemMutation: zCustomMutation(config.internalMutation, {
|
|
465
|
-
args: {},
|
|
466
|
-
input: async (ctx) => ({ ctx: { ...wrapDB(ctx), include }, args: {} }),
|
|
467
|
-
}),
|
|
468
|
-
systemAction: zCustomAction(config.internalAction, {
|
|
469
|
-
args: {},
|
|
470
|
-
input: async (ctx) => ({ ctx, args: {} }),
|
|
471
|
-
}),
|
|
472
|
-
}
|
|
473
|
-
}
|