@vxil/cli 0.28.0 → 0.29.0

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.
@@ -52,6 +52,26 @@ export declare function isOrgChannelTemplate(channel: string): boolean;
52
52
  * `org:<id>`). Null (publish nothing) for a non-template or an org id that is
53
53
  * not a string matching ORG_CHANNEL_ID_RE — fail closed. */
54
54
  export declare function resolveOrgChannel(template: string, orgId: unknown): string | null;
55
+ /** MX-1022-CDC-OWNER-CHANNEL: the owner template token — one WHOLE segment of a
56
+ * cdc `channel` on an owner-scoped (ownerField) collection. Each row publishes
57
+ * to its owner's private channel `user:<owner>:<rest>` (realtime-v1
58
+ * userChannelDenied binds an end-user token for a `user:` channel to its
59
+ * verified id). Never combined with `{org}` in one channel. */
60
+ export declare const CDC_OWNER_TOKEN = "{owner}";
61
+ /** Every resolved owner channel starts with this prefix (realtime-v1
62
+ * token.ts USER_CHANNEL_PREFIX; the `cdc-gated-fields` gate pins both equal). */
63
+ export declare const USER_CHANNEL_PREFIX = "user:";
64
+ /** An owner id that may enter a channel name: no ':' / '@' (realtime refuses
65
+ * such ids every `user:` channel anyway), ≤ 128 chars. */
66
+ export declare const OWNER_CHANNEL_ID_RE: RegExp;
67
+ /** Is `channel` an owner template — exactly one segment equal to `{owner}`,
68
+ * every other segment a non-empty channel-alphabet run, ≤ 128 chars? */
69
+ export declare function isOwnerChannelTemplate(channel: string): boolean;
70
+ /** Resolve an owner template for one row: drop the `{owner}` segment and prefix
71
+ * `user:<id>:` (`meals:{owner}` → `user:<id>:meals`). Null (publish nothing)
72
+ * for a non-template, an owner id that is not a string matching
73
+ * OWNER_CHANNEL_ID_RE, or a result past realtime's 200-char cap — fail closed. */
74
+ export declare function resolveOwnerChannel(template: string, ownerId: unknown): string | null;
55
75
  /** Cross-field rule for CmsConfig.cdc (called from validateFeatureConfig). */
56
76
  export declare function validateCdcConfig(cdc: Record<string, CdcRuleLike> | undefined): string[];
57
77
  /** What a payload:'full' change-data rule must know about its target
@@ -90,4 +110,14 @@ export declare function cdcFullFramesOverGatedCollections(cdc: unknown, collecti
90
110
  * Pure; one problem line per offending ENABLED rule.
91
111
  */
92
112
  export declare function cdcFixedChannelsOverOrgCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
113
+ /**
114
+ * MX-1022-CDC-OWNER-CHANNEL: an `{owner}` rule is valid only on an owner-scoped
115
+ * collection (a live `ownerField`) that is NOT a workspace collection: a
116
+ * `user:` channel skips realtime's active-org check, so a member removed from a
117
+ * workspace who still owns rows there would keep receiving them — `{org}`
118
+ * serves workspace collections. The cms runtime floor (cdc.ts cdcChannelFor)
119
+ * publishes nothing for such a rule at every door that cannot check. Pure; one
120
+ * problem line per offending ENABLED rule (an unknown collection is skipped).
121
+ */
122
+ export declare function cdcOwnerRulesOverNonOwnerCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
93
123
  export {};
@@ -313,6 +313,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
313
313
  resendCooldownSec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
314
314
  }>>;
315
315
  registryAdopt: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
316
+ userIdFormat: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"ulid">, import("@sinclair/typebox").TLiteral<"uuid">]>>;
316
317
  otp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
317
318
  enabled: import("@sinclair/typebox").TBoolean;
318
319
  codeTtlMinutes: import("@sinclair/typebox").TInteger;
@@ -710,6 +711,7 @@ export declare const AiConfigSchema: import("@sinclair/typebox").TObject<{
710
711
  system: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
711
712
  schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnknown>>;
712
713
  }>>>;
714
+ testMarkers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
713
715
  }>;
714
716
  export type AiConfig = Static<typeof AiConfigSchema>;
715
717
  export declare const RagConfigSchema: import("@sinclair/typebox").TObject<{
@@ -805,6 +807,13 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
805
807
  deferDowngrades: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
806
808
  subPeriodGrants: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
807
809
  }>>;
810
+ refunds: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
811
+ cancelAtProvider: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"off">, import("@sinclair/typebox").TLiteral<"period_end">, import("@sinclair/typebox").TLiteral<"immediately">]>>;
812
+ }>>;
813
+ disputes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
814
+ cancelAtProvider: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"off">, import("@sinclair/typebox").TLiteral<"period_end">]>>;
815
+ }>>;
816
+ shadow: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
808
817
  }>;
809
818
  export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
810
819
  export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<{
@@ -916,12 +925,12 @@ export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
916
925
  /** The events a `cmsHook` function binding may name — the closed union
917
926
  * `packages/config` types on the `cmsHook` trigger, and the keys of the
918
927
  * functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
919
- * beforeUpdate → updated, beforeWrite → both, afterPublish → published). Omitted = beforeWrite. Anything
928
+ * beforeUpdate → updated, beforeWrite → both, afterPublish → published, afterDelete → deleted). Omitted = beforeWrite. Anything
920
929
  * else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
921
930
  * to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
922
931
  * 'beforeDelete' silently subscribed the function to creates AND updates.
923
932
  * A CI gate pins every copy to this list. */
924
- export declare const CMS_HOOK_EVENTS: readonly ["beforeCreate", "beforeUpdate", "beforeWrite", "afterPublish"];
933
+ export declare const CMS_HOOK_EVENTS: readonly ["beforeCreate", "beforeUpdate", "beforeWrite", "afterPublish", "afterDelete"];
925
934
  export type CmsHookEvent = (typeof CMS_HOOK_EVENTS)[number];
926
935
  /** The ONE refusal text for an unknown cmsHook binding event (the config
927
936
  * validator and the deploy route's early 422 both use it). */
@@ -1014,3 +1023,11 @@ export declare function isDeclaredRetainDays(v: unknown): v is number | null | u
1014
1023
  * collection that change applies only under allow-destructive; lengthening
1015
1024
  * or clearing keeps more and always applies. */
1016
1025
  export declare function retainDaysShortens(declared: number | null, remote: number | null): boolean;
1026
+ /** = cms-v1 core.ts DELETED_RETAIN_DAYS_MAX (a parity test pins the two). */
1027
+ export declare const CMS_DELETED_RETAIN_DAYS_MAX = 29;
1028
+ export declare function isDeclaredDeletedRetainDays(v: unknown): v is number | null | undefined;
1029
+ export declare function isDeclaredOnOwnerErase(v: unknown): v is 'delete' | 'keep' | undefined;
1030
+ /** Does moving from `remote` to `declared` make an erase delete MORE (turning
1031
+ * 'delete' on)? On an existing collection that applies only under
1032
+ * allow-destructive; turning it off always applies. */
1033
+ export declare function onOwnerEraseWidens(declared: 'delete' | 'keep', remote: 'delete' | 'keep'): boolean;
package/dist/config.d.ts CHANGED
@@ -140,6 +140,24 @@ export interface CollectionDef {
140
140
  * shortening it needs `vxil push --allow-destructive` (lengthening or
141
141
  * `null` always applies). Carried by BOTH push paths. */
142
142
  retainDays?: number | null;
143
+ /** Recovery window for DELETED items, in days (guide ch. 4): a soft-deleted
144
+ * item stays restorable this long before the nightly cleanup purges it for
145
+ * good. An integer 0..29 (0 = purged at the next nightly run, ≤ 24 h), or
146
+ * `null` for the platform default of 30 days. ABSENT = not managed by
147
+ * config. Shortening it on an existing collection needs
148
+ * `vxil push --allow-destructive`. Carried by BOTH push paths. */
149
+ deletedRetainDays?: number | null;
150
+ /** What a GDPR erase of a user does to the items they OWN here (guide ch. 9;
151
+ * needs `ownerField` — "owner" is whoever that field names):
152
+ * 'keep' (default) — the items stay; the owner id resolves to the
153
+ * erased tombstone user;
154
+ * 'delete' — the erase hard-deletes them (live and deleted, unique values
155
+ * freed) in the same transaction. Relations elsewhere are not
156
+ * cascaded (they keep a dangling id).
157
+ * ABSENT = not managed by config. Turning 'delete' on for an existing
158
+ * collection needs `vxil push --allow-destructive`. Carried by BOTH push
159
+ * paths. */
160
+ onOwnerErase?: 'delete' | 'keep';
143
161
  /** Per-record action buttons (guide ch. 4): `[{ key, label, fn }]`, stored on
144
162
  * the collection (model data, not a config leaf). The dashboard renders one
145
163
  * button per action on each record row; `POST /v1/cms/items/:coll/:id/actions/
@@ -303,12 +321,14 @@ export type FunctionTrigger =
303
321
  * `beforeUpdate` → updated only, `beforeWrite` → created|updated,
304
322
  * `afterPublish` → published (a draft→published transition is its own event,
305
323
  * not an update); a write to another collection never wakes the function.
306
- * Deleted/bulk events are not delivered to a cmsHook binding. `event`
324
+ * `afterDelete` → deleted (one event per deleted root row — cascaded children
325
+ * and GDPR erase emit none; the payload carries `id`, `owner` (+ `org`), never
326
+ * the row body). Bulk-summary events are not delivered to a cmsHook binding. `event`
307
327
  * defaults to `beforeWrite`. */
308
328
  | {
309
329
  kind: 'cmsHook';
310
330
  collection: string;
311
- event?: 'beforeCreate' | 'beforeUpdate' | 'beforeWrite' | 'afterPublish';
331
+ event?: 'beforeCreate' | 'beforeUpdate' | 'beforeWrite' | 'afterPublish' | 'afterDelete';
312
332
  retry?: FunctionTriggerRetry;
313
333
  } | {
314
334
  kind: 'authHook';
package/dist/vxil-main.js CHANGED
@@ -4004,6 +4004,13 @@ var AuthConfigSchema = Type.Object({
4004
4004
  // of security semantics for a tenant that bulk-imports contacts, whose
4005
4005
  // addresses vxil never verified.
4006
4006
  registryAdopt: Type.Optional(Type.Boolean()),
4007
+ // MX-1022-USER-ID-UUID (2026-10-22): the shape of NEWLY minted user ids.
4008
+ // 'ulid' ⇒ `user_<ulid>` (the historical shape); 'uuid' ⇒ a bare RFC 4122 v4
4009
+ // uuid (crypto.randomUUID), for tenants whose own schema keys users by uuid.
4010
+ // Optional with NO default: absent ⇒ 'ulid' (auth-v1 `mintUserId` owns the
4011
+ // default), so existing manifests stay byte-identical. Existing ids are never
4012
+ // rewritten — flipping it only changes users created afterwards.
4013
+ userIdFormat: Type.Optional(Type.Union([Type.Literal("ulid"), Type.Literal("uuid")])),
4007
4014
  // Email OTP sign-in + the knobs step-up re-auth shares.
4008
4015
  // OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
4009
4016
  // otp?.enabled === true).
@@ -4779,7 +4786,16 @@ var AiConfigSchema = Type.Object({
4779
4786
  // in place — RETIRED (soft: hidden from list + render, history kept) only
4780
4787
  // under --allow-destructive (2026-10-01). Bounded to 50 entries: the
4781
4788
  // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
4782
- templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES }))
4789
+ templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
4790
+ // Test markers: honour the `[[provider_fail]]` /
4791
+ // `[[provider_fail:retryable]]` prompt marker on a REAL provider, so a staging
4792
+ // project can test its failure handling (the run ends FAILED, hold released,
4793
+ // `job.generation.failed` / ProviderFailed, no provider call, no cost). The
4794
+ // mock provider always honours it. No default: absent = off, so existing
4795
+ // manifests are byte-identical. Config-only and versioned (visible in
4796
+ // `vxil diff`); deliberately never a request header. Set it in a staging
4797
+ // config only.
4798
+ testMarkers: Type.Optional(Type.Boolean())
4783
4799
  });
4784
4800
  var RagConfigSchema = Type.Object({
4785
4801
  enabled: Type.Boolean({ default: true }),
@@ -5054,7 +5070,38 @@ var PaymentsConfigSchema = Type.Object({
5054
5070
  // sized a yearly plan's `amount` for the year would mint it every month.
5055
5071
  // No schema default; the reader treats absent as false.
5056
5072
  subPeriodGrants: Type.Optional(Type.Boolean())
5057
- }))
5073
+ })),
5074
+ // (2026-10-22 PAY-REFUND-CANCEL-OPTIN) The tenant default for `POST
5075
+ // /v1/payments/refunds` calls that send no `cancel`: also stop the provider
5076
+ // subscription the refunded charge paid for, once the refund has settled.
5077
+ // off (default): refund and cancel stay separate (the provider keeps billing
5078
+ // until DELETE /subscriptions/{id}); period_end; immediately (Stripe only —
5079
+ // elsewhere it runs as period_end). A per-call `cancel` always wins. No
5080
+ // schema default (Value.Default would materialize it into every manifest);
5081
+ // the reader owns `off`.
5082
+ refunds: Type.Optional(Type.Object({
5083
+ cancelAtProvider: Type.Optional(Type.Union([
5084
+ Type.Literal("off"),
5085
+ Type.Literal("period_end"),
5086
+ Type.Literal("immediately")
5087
+ ]))
5088
+ })),
5089
+ // (2026-10-22 PAY-REFUND-CANCEL-OPTIN, dispute half) When a charge is
5090
+ // disputed vxil ends the subscription row, but the provider keeps billing.
5091
+ // period_end: also schedule the provider subscription to stop at period end
5092
+ // (Stripe + mock only; elsewhere a recorded no-op). No `immediately`: a won
5093
+ // dispute reinstates the row, and an immediate cancel can't be undone. Absent
5094
+ // ⇒ off (the reader owns the default).
5095
+ disputes: Type.Optional(Type.Object({
5096
+ cancelAtProvider: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("period_end")]))
5097
+ })),
5098
+ // (2026-10-22 PAYMENTS-SHADOW) Run vxil beside an existing billing system:
5099
+ // webhooks still fold into vxil's state (and ledger tier grants still run),
5100
+ // but the business `payments.*` events (subscription/charge/entitlement/
5101
+ // grant) are not written, so outbound webhooks and function triggers stay
5102
+ // quiet. Diagnostics (webhook.rejected, webhook_event.*, reconcile.*,
5103
+ // conformance, refund.failed) still flow. Absent ⇒ false.
5104
+ shadow: Type.Optional(Type.Boolean())
5058
5105
  // NB: the former `webhooks.forwardToTenantUrl` leaf
5059
5106
  // was DELETED — it had zero readers (never forwarded anything). Outbound
5060
5107
  // delivery of payments state changes rides the audit stream → outbound
@@ -5144,7 +5191,7 @@ var FunctionsConfigSchema = Type.Object({
5144
5191
  collection: Type.Optional(Type.String()),
5145
5192
  // cmsHook: the CMS collection slug
5146
5193
  event: Type.Optional(Type.String()),
5147
- // cmsHook: beforeCreate|beforeUpdate|beforeWrite|afterPublish · authHook: 'user.created'
5194
+ // cmsHook: beforeCreate|beforeUpdate|beforeWrite|afterPublish|afterDelete · authHook: 'user.created'
5148
5195
  // (2026-09-25) the per-binding opt-in to re-delivery on
5149
5196
  // queue / webhook / cmsHook / authHook, and since 2026-10-03 on
5150
5197
  // cron and http (the async lane only). Absent = the ACK-200 default. The
@@ -5364,6 +5411,16 @@ function retainDaysShortens(declared, remote) {
5364
5411
  if (declared === null) return false;
5365
5412
  return remote === null || declared < remote;
5366
5413
  }
5414
+ var CMS_DELETED_RETAIN_DAYS_MAX = 29;
5415
+ function isDeclaredDeletedRetainDays(v) {
5416
+ return v === void 0 || v === null || typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= CMS_DELETED_RETAIN_DAYS_MAX;
5417
+ }
5418
+ function isDeclaredOnOwnerErase(v) {
5419
+ return v === void 0 || v === "delete" || v === "keep";
5420
+ }
5421
+ function onOwnerEraseWidens(declared, remote) {
5422
+ return declared === "delete" && remote !== "delete";
5423
+ }
5367
5424
 
5368
5425
  // ../config/src/index.ts
5369
5426
  var CMS_ROLE_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
@@ -5584,9 +5641,9 @@ function lowerAllTriggerBindings(def, name) {
5584
5641
  return bindings;
5585
5642
  }
5586
5643
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5587
- var VXIL_CONFIG_PKG_VERSION = "0.20.0";
5588
- var VXIL_SDK_PKG_VERSION = "0.27.0";
5589
- var VXIL_CLI_PKG_VERSION = "0.28.0";
5644
+ var VXIL_CONFIG_PKG_VERSION = "0.21.0";
5645
+ var VXIL_SDK_PKG_VERSION = "0.28.0";
5646
+ var VXIL_CLI_PKG_VERSION = "0.29.0";
5590
5647
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5591
5648
  const file = resolve(cwd, "package.json");
5592
5649
  const wanted = {
@@ -9696,6 +9753,8 @@ function remoteCollectionOf(c) {
9696
9753
  orgField: typeof c.org_field === "string" && c.org_field.length > 0 ? c.org_field : null,
9697
9754
  orgReadRoles: writeRolesOf(c.org_read_roles),
9698
9755
  retainDays: typeof c.retain_days === "number" ? c.retain_days : null,
9756
+ deletedRetainDays: typeof c.deleted_retain_days === "number" ? c.deleted_retain_days : null,
9757
+ onOwnerErase: c.on_owner_erase === "delete" ? "delete" : "keep",
9699
9758
  actions: normalizeActions(c.actions),
9700
9759
  fields: Array.isArray(c.fields) ? c.fields : []
9701
9760
  };
@@ -9713,6 +9772,8 @@ function pulledCollectionBlock(c) {
9713
9772
  ...rc.orgField ? { orgField: rc.orgField } : {},
9714
9773
  ...rc.orgReadRoles.length ? { orgReadRoles: rc.orgReadRoles } : {},
9715
9774
  ...rc.retainDays !== null ? { retainDays: rc.retainDays } : {},
9775
+ ...rc.deletedRetainDays !== null ? { deletedRetainDays: rc.deletedRetainDays } : {},
9776
+ ...rc.onOwnerErase === "delete" ? { onOwnerErase: "delete" } : {},
9716
9777
  ...rc.actions.length ? { actions: rc.actions } : {},
9717
9778
  fields
9718
9779
  };
@@ -9783,6 +9844,14 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9783
9844
  if (!isDeclaredRetainDays(rd)) {
9784
9845
  throw new Error(`cms.collections.${name}.retainDays must be an integer 1..3650 (days) or null (got ${JSON.stringify(rd)})`);
9785
9846
  }
9847
+ const drd = def.deletedRetainDays;
9848
+ if (!isDeclaredDeletedRetainDays(drd)) {
9849
+ throw new Error(`cms.collections.${name}.deletedRetainDays must be an integer 0..29 (days) or null (got ${JSON.stringify(drd)})`);
9850
+ }
9851
+ const ooe = def.onOwnerErase;
9852
+ if (!isDeclaredOnOwnerErase(ooe)) {
9853
+ throw new Error(`cms.collections.${name}.onOwnerErase must be 'delete' or 'keep' (got ${JSON.stringify(ooe)})`);
9854
+ }
9786
9855
  }
9787
9856
  const read = await readRemote2(api);
9788
9857
  if (read.unavailable) {
@@ -10020,6 +10089,33 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
10020
10089
  });
10021
10090
  }
10022
10091
  }
10092
+ const declaredWindow = def.deletedRetainDays;
10093
+ if (declaredWindow !== void 0 && declaredWindow !== rc.deletedRetainDays) {
10094
+ const fmtWin = (d) => d === null ? "30 days (default)" : `${d} days`;
10095
+ const shortens = retainDaysShortens(declaredWindow, rc.deletedRetainDays);
10096
+ collChanges.push(!shortens || allowDestructive ? {
10097
+ collection: name,
10098
+ kind: "set-deleted-retain-days",
10099
+ detail: `${name} deletedRetainDays \u2192 ${fmtWin(declaredWindow)} (was ${fmtWin(rc.deletedRetainDays)})` + (shortens ? " \u2014 deleted items are purged sooner" : "")
10100
+ } : {
10101
+ collection: name,
10102
+ kind: "warn",
10103
+ detail: `${name}: deletedRetainDays is ${fmtWin(rc.deletedRetainDays)} remotely but ${fmtWin(declaredWindow)} in config \u2014 that purges deleted items sooner, so it is NOT applied. Re-run with --allow-destructive to apply it.`
10104
+ });
10105
+ }
10106
+ const declaredErase = def.onOwnerErase;
10107
+ if (declaredErase !== void 0 && declaredErase !== rc.onOwnerErase) {
10108
+ const widens = onOwnerEraseWidens(declaredErase, rc.onOwnerErase);
10109
+ collChanges.push(!widens || allowDestructive ? {
10110
+ collection: name,
10111
+ kind: "set-on-owner-erase",
10112
+ detail: `${name} onOwnerErase \u2192 ${declaredErase} (was ${rc.onOwnerErase})` + (widens ? " \u2014 a user's erase then hard-deletes the items they own" : "")
10113
+ } : {
10114
+ collection: name,
10115
+ kind: "warn",
10116
+ detail: `${name}: onOwnerErase is ${rc.onOwnerErase} remotely but ${declaredErase} in config \u2014 that makes a user's erase hard-delete the items they own, so it is NOT applied. Re-run with --allow-destructive to apply it.`
10117
+ });
10118
+ }
10023
10119
  const declaredActions = normalizeActions(def.actions);
10024
10120
  if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
10025
10121
  collChanges.push({
@@ -10099,6 +10195,8 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
10099
10195
  const orgRoles = writeRolesOf(def.orgReadRoles);
10100
10196
  if (orgRoles.length > 0) body.org_read_roles = orgRoles;
10101
10197
  if (typeof def.retainDays === "number") body.retain_days = def.retainDays;
10198
+ if (typeof def.deletedRetainDays === "number") body.deleted_retain_days = def.deletedRetainDays;
10199
+ if (def.onOwnerErase === "delete") body.on_owner_erase = "delete";
10102
10200
  if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
10103
10201
  const res = await api("POST", "/v1/cms/collections", body);
10104
10202
  if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
@@ -10180,6 +10278,15 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
10180
10278
  throw new Error(`cms: set retainDays on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
10181
10279
  }
10182
10280
  applied++;
10281
+ } else if (c.kind === "set-deleted-retain-days" || c.kind === "set-on-owner-erase") {
10282
+ const def = collections[c.collection];
10283
+ const body = c.kind === "set-deleted-retain-days" ? { deleted_retain_days: def.deletedRetainDays ?? null } : { on_owner_erase: def.onOwnerErase ?? "keep" };
10284
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, body);
10285
+ if (res.status !== 200 && res.status !== 201) {
10286
+ const e = res.body.error ?? {};
10287
+ throw new Error(`cms: set ${Object.keys(body)[0]} on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
10288
+ }
10289
+ applied++;
10183
10290
  } else if (c.kind === "set-actions") {
10184
10291
  const target = normalizeActions(collections[c.collection].actions);
10185
10292
  const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
@@ -10245,7 +10352,7 @@ function formatCmsChanges(changes) {
10245
10352
  if (c.kind === "destructive") return ` ! ${c.detail}`;
10246
10353
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
10247
10354
  if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
10248
- if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles" || c.kind === "set-retain-days") return ` ~ ${c.detail}`;
10355
+ if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles" || c.kind === "set-retain-days" || c.kind === "set-deleted-retain-days" || c.kind === "set-on-owner-erase") return ` ~ ${c.detail}`;
10249
10356
  return ` + ${c.detail}`;
10250
10357
  }).join("\n");
10251
10358
  }
@@ -12202,7 +12309,8 @@ var CMS_HOOK_EVENTS_FOR = {
12202
12309
  beforeCreate: ["cms.item.created"],
12203
12310
  beforeUpdate: ["cms.item.updated"],
12204
12311
  beforeWrite: ["cms.item.created", "cms.item.updated"],
12205
- afterPublish: ["cms.item.published"]
12312
+ afterPublish: ["cms.item.published"],
12313
+ afterDelete: ["cms.item.deleted"]
12206
12314
  };
12207
12315
  function cmsHookBindingMatches(bindings, event, collection) {
12208
12316
  const hooks = (bindings ?? []).filter((b2) => b2.kind === "cmsHook" || b2.kind === "cms-hook");
@@ -12759,7 +12867,7 @@ async function importNdjson(api, feature, pieces, mode, opts = {}) {
12759
12867
  }
12760
12868
  tables.set(t.table, acc);
12761
12869
  }
12762
- for (const k of ["warnings", "ignored_tables", "replace_retained", "truncated_tables"]) {
12870
+ for (const k of ["warnings", "ignored_tables", "notes", "replace_retained", "truncated_tables"]) {
12763
12871
  if (Array.isArray(d[k])) for (const s of d[k]) {
12764
12872
  const set = extras.get(k) ?? /* @__PURE__ */ new Set();
12765
12873
  set.add(String(s));
@@ -13142,7 +13250,7 @@ var TOOLS = [
13142
13250
  {
13143
13251
  name: "notifications_send",
13144
13252
  feature: "notifications",
13145
- description: "Send a transactional email to an end-user by ID. Returns a delivery_id immediately (202); delivery is asynchronous. Templates: magic-link (data: url, expires_minutes), otp-code (data: code, expires_minutes), welcome (data: app_name, first_name?), transactional (data: subject, paragraph, cta_label?, cta_url?).",
13253
+ description: 'Send a transactional email to an end-user by ID. Returns a delivery_id immediately (202); delivery is asynchronous. Templates: magic-link (data: url, expires_minutes), otp-code (data: code, expires_minutes), welcome (data: app_name, first_name?), transactional (data: subject, paragraph, cta_label?, cta_url?, unsubscribe_url?). unsubscribe_url is a link value only (http/https/mailto/tel, anything else becomes #): write the labelled <a href="{{unsubscribe_url}}"> in your own template override; the built-in body adds nothing for it.',
13146
13254
  inputSchema: {
13147
13255
  type: "object",
13148
13256
  properties: {
@@ -14021,7 +14129,7 @@ var TOOLS = [
14021
14129
  {
14022
14130
  name: "cms_list_collections",
14023
14131
  feature: "cms",
14024
- description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago. org_field (null = none) makes the collection workspace-scoped: a verified end user reads and writes only rows of the session's active org (no active org \u2192 403 org_required) and write_roles match the active-org role; org_read_roles (null = any member) gates reads by that role.",
14132
+ description: "List the tenant's content model: collections with their typed field definitions (the schema items are validated against). Each field row carries its attributes \u2014 required, validation, index_slot, relation_to, computed/compute, is_unique, on_delete, read_roles (the end-user org roles allowed to READ that field; null = ungated), and on an equality-indexed field indexed: true + index_ready (equality filters on it are index-served once ready; until then, and on unindexed unslotted fields, they are a bounded scan \u2014 same results). A field with read_roles is omitted from end-user reads without a matching role and is never served on the public lane, so a missing key in an item's data may be a read gate rather than an unset value. Each collection row also carries end_user_access: 'readwrite' (default), 'read' (a verified end user \u2014 a thin-client key with a session \u2014 may read but every write answers 403 server_only) or 'none' (end-user reads and writes are 403 server_only), and write_roles: the roles a verified end user must hold to WRITE the collection (null = ungated; without one every end-user write answers 403 role_required, reads are unaffected); server keys are never affected. retain_days (null = keep) is the collection's retention: the nightly platform sweep soft-deletes live items created more than that many days ago. deleted_retain_days (null = 30) is how many days a deleted item stays restorable before the nightly cleanup purges it. on_owner_erase 'delete' (null = keep) means a GDPR erase of a user hard-deletes the items they own here. org_field (null = none) makes the collection workspace-scoped: a verified end user reads and writes only rows of the session's active org (no active org \u2192 403 org_required) and write_roles match the active-org role; org_read_roles (null = any member) gates reads by that role.",
14025
14133
  inputSchema: { type: "object", properties: {} },
14026
14134
  method: "GET",
14027
14135
  path: "/v1/cms/collections"
@@ -14818,12 +14926,18 @@ var TOOLS = [
14818
14926
  type: "object",
14819
14927
  properties: {
14820
14928
  generation_id: { type: "string", description: "The generation_id from the generate answer (use this OR correlation_id)." },
14821
- correlation_id: { type: "string", minLength: 1, maxLength: 128, description: "The correlation_id you sent on ai_generate / the REST generate call." }
14929
+ correlation_id: { type: "string", minLength: 1, maxLength: 128, description: "The correlation_id you sent on ai_generate / the REST generate call." },
14930
+ wait: {
14931
+ type: "integer",
14932
+ minimum: 1,
14933
+ maximum: 25,
14934
+ description: "With generation_id only: hold the read up to this many seconds until the generation is completed or failed (one call instead of polling). Still running at the deadline: the current record comes back (response header x-vxil-wait: timeout). Not valid with correlation_id (that lookup answers 422 unknown query parameter)."
14935
+ }
14822
14936
  }
14823
14937
  },
14824
14938
  method: "GET",
14825
14939
  path: (a) => a.generation_id ? `/v1/ai/generations/${encodeURIComponent(String(a.generation_id))}` : "/v1/ai/generations",
14826
- queryArgs: ["correlation_id"]
14940
+ queryArgs: ["correlation_id", "wait"]
14827
14941
  },
14828
14942
  {
14829
14943
  name: "ai_classify",
@@ -15091,7 +15205,7 @@ var TOOLS = [
15091
15205
  // Substrate tool (no `feature` — always-on like users_upsert): usage is a
15092
15206
  // platform property, not a feature you enable.
15093
15207
  name: "usage_current",
15094
- description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only), `files` (live private objects and bytes vs the storage `limit` uploads are held to \u2014 422 quota_exceeded at it) and `functions` (today's UTC call count vs the plan's `limit_per_day`, and on a Free staging/development project `cpu_ms_today` vs its `cpu_ms_limit_per_day` CPU budget \u2014 429 cpu_ms_per_day past it; a snapshot that can trail by a minute or two, null when there is no snapshot for today). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers have no monthly cap and are never billed for overage. Needs usage:read or features:read.",
15208
+ description: "Current-month usage for this project: requests used, the plan's included quota, remaining (null when the plan has no fixed cap), month-to-date per-feature metered detail (may lag \u2014 it is aggregated periodically), and `routes` \u2014 the same request count split by the surface that served it (the resolved upstream; /v1/fn/* reports as 'fn-invoke', busiest first, observational only), `files` (live private objects and bytes vs the storage `limit` uploads are held to \u2014 422 quota_exceeded at it) and `functions` (today's UTC call count vs the plan's `limit_per_day`, and on a Free staging/development project `cpu_ms_today` vs its `cpu_ms_limit_per_day` CPU budget \u2014 429 cpu_ms_per_day past it; a snapshot that can trail by a minute or two, null when there is no snapshot for today), and `daily_caps` \u2014 each daily lane of the plan (api_calls, App builder units, functions CPU, realtime, bandwidth, image transforms, and db_bytes \u2014 the database size measured nightly, over which cms creates and updates answer 413 db_size_limit) with `used` (null where not readable yet), `cap`, `resets_at` (null for db_bytes) and `state` ('warning' at 80 %, 'reached' at the cap), for the pool the caps count in (a Free account's projects share one). Read-only. Call this before a bulk run to self-check remaining quota: the Free tier is hard-capped (429 quota_exceeded when exhausted); paid tiers have no monthly cap and are never billed for overage. Needs usage:read or features:read.",
15095
15209
  inputSchema: { type: "object", properties: {} },
15096
15210
  method: "GET",
15097
15211
  path: "/v1/usage"
@@ -15728,6 +15842,23 @@ function paymentsWebhookSecretChecks(manifest) {
15728
15842
  }
15729
15843
  return [{ name: "payments webhook secret", ok: true, warn: true, detail: `${provider}: ${PAYMENTS_WEBHOOK_SECRET_NOTE}` }];
15730
15844
  }
15845
+ var PAYMENTS_SHADOW_NOTE = "payments.shadow is on: provider webhooks still update vxil's subscription state, but business payments.* events (subscription/charge/entitlement/grant) are NOT written \u2014 outbound webhooks and function triggers on them stay quiet. Diagnostics still flow. Turn it off at cutover";
15846
+ var PAYMENTS_SHADOW_GRANTS_NOTE = "payments.shadow is on AND ledger tier grants are configured: grants still mint credits into the vxil ledger during shadow, so credits your old system also grants will be counted twice at cutover";
15847
+ var PAYMENTS_DISPUTE_CANCEL_UNSUPPORTED_NOTE = "disputes.cancelAtProvider: period_end runs on stripe and mock only; on this provider it is recorded as unsupported and the provider subscription keeps billing";
15848
+ function paymentsShadowChecks(manifest) {
15849
+ if (!manifest || manifest.enabled === false) return [];
15850
+ const out = [];
15851
+ if (manifest.shadow === true) {
15852
+ out.push({ name: "payments shadow", ok: true, warn: true, detail: PAYMENTS_SHADOW_NOTE });
15853
+ const grants = Object.values(manifest.ledger?.tierMap ?? {}).some((t) => (t?.grants?.length ?? 0) > 0);
15854
+ if (grants) out.push({ name: "payments shadow grants", ok: true, warn: true, detail: PAYMENTS_SHADOW_GRANTS_NOTE });
15855
+ }
15856
+ const provider = String(manifest.provider ?? "");
15857
+ if (manifest.disputes?.cancelAtProvider === "period_end" && provider !== "stripe" && provider !== "mock") {
15858
+ out.push({ name: "payments dispute cancel", ok: true, warn: true, detail: `${provider}: ${PAYMENTS_DISPUTE_CANCEL_UNSUPPORTED_NOTE}` });
15859
+ }
15860
+ return out;
15861
+ }
15731
15862
  function sha12Of(scriptRef) {
15732
15863
  if (!scriptRef) return void 0;
15733
15864
  const m = /-([0-9a-f]{12})$/.exec(scriptRef);
@@ -18500,6 +18631,8 @@ function credentialEntry(row2, withHashes) {
18500
18631
  id: row2.id,
18501
18632
  ...withHashes && row2.password_hash ? { password_hash: row2.password_hash } : {},
18502
18633
  ...row2.email_verified ? { email_verified: true } : {},
18634
+ ...row2.created_at ? { created_at: row2.created_at } : {},
18635
+ ...row2.email_verified_at ? { email_verified_at: row2.email_verified_at } : {},
18503
18636
  ...identities.length > 0 ? { identities } : {}
18504
18637
  };
18505
18638
  }
@@ -18740,6 +18873,7 @@ function apiErr(prefix, res) {
18740
18873
  }
18741
18874
 
18742
18875
  // src/migrate/loaders/files.ts
18876
+ import { createHash as createHash3 } from "node:crypto";
18743
18877
  async function materialize(s) {
18744
18878
  if (s instanceof Uint8Array) return s;
18745
18879
  const chunks = [];
@@ -18965,7 +19099,12 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18965
19099
  ...orgId ? { org_id: orgId } : {},
18966
19100
  filename: obj.path.split("/").pop() ?? obj.path,
18967
19101
  content_type: contentType,
18968
- size_bytes: bytes.length
19102
+ size_bytes: bytes.length,
19103
+ // MX-1022-MIGRATE-FILES-SAFE: a migrated original never inherits the
19104
+ // bucket's expiry default (meant for NEW uploads), and the store verifies
19105
+ // its bytes against this checksum at complete.
19106
+ expiresInSeconds: null,
19107
+ checksum_sha256: createHash3("sha256").update(bytes).digest("hex")
18969
19108
  });
18970
19109
  if (created.status !== 201 && created.status !== 200) {
18971
19110
  const code = created.body.error?.code;
@@ -18974,12 +19113,13 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18974
19113
  }
18975
19114
  const objectId = created.body.data?.object_id;
18976
19115
  const uploadUrl = created.body.data?.upload_url;
19116
+ const uploadHeaders = created.body.data?.upload_headers;
18977
19117
  if (typeof objectId !== "string" || typeof uploadUrl !== "string") {
18978
19118
  return `files: upload-url for '${obj.path}' returned no object_id/upload_url`;
18979
19119
  }
18980
19120
  const putRes = await put(uploadUrl, {
18981
19121
  method: "PUT",
18982
- headers: { "content-type": contentType },
19122
+ headers: isStringRecord(uploadHeaders) ? uploadHeaders : { "content-type": contentType },
18983
19123
  body: bytes
18984
19124
  });
18985
19125
  if (!putRes.ok) {
@@ -18992,6 +19132,9 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18992
19132
  map2[obj.path] = objectId;
18993
19133
  return void 0;
18994
19134
  }
19135
+ function isStringRecord(v) {
19136
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
19137
+ }
18995
19138
 
18996
19139
  // src/migrate/loaders/payments.ts
18997
19140
  async function loadPaymentsCredits({ deps, adapter, mapping, state, save, spec, pkColumn, delta }) {
@@ -21157,7 +21300,7 @@ export default defineConfig({
21157
21300
  "hasFunctions": true,
21158
21301
  "byoKeys": [],
21159
21302
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Client Portal\" \u2014 the AGENCY / B2B portal: one WORKSPACE per client. The\n// client's people sign in as members of their own workspace, see only their\n// own requests and shared files, and get mail that comes from THEIR portal.\n// Declared end-to-end in ONE typed file.\n//\n// \u2022 auth + orgs \u2192 one workspace per client; client members are `viewer`s,\n// your staff are `admin`s of every client they serve; the\n// session acts in ONE active workspace (switchOrg)\n// \u2022 cms \u2192 `requests` (tickets), workspace-scoped (orgField): a\n// client opens and follows requests, only staff move them\n// \u2022 files \u2192 shared workspace files: clients upload and read, staff\n// (or the uploader) delete\n// \u2022 realtime \u2192 a live request board per client ('requests:{org}')\n// \u2022 notifications \u2192 mail sent AS the client's portal (a per-send sender:\n// \"Acme portal\" <acme@\u2026>), on new requests and status changes\n// \u2022 functions \u2192 the one notifier\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n methods: { emailPassword: true, magicLink: true },\n orgClaims: { enabled: true, activeOrg: true }, // one active client workspace per session\n session: { ttlMinutes: 15, refreshTtlDays: 30 },\n },\n\n orgs: {\n enabled: true,\n maxOrgsPerTenant: 500, // clients served by this project (up to 100,000)\n maxMembersPerOrg: 25, // client people + your staff, per client\n invitationTtlHours: 168,\n // Built-in roles only: `viewer` for the client's people, `admin` for\n // your staff on that client, `owner` for the account lead.\n },\n\n cms: {\n draftPublish: false,\n hooks: {\n // Who opened it comes from the verified session, never from the body.\n // (Your server key \u2014 `caller` is null \u2014 may set it, e.g. on an import.)\n request_requester: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'requester',\n expr: 'isNull(caller) ? item.requester : caller.endUserId',\n },\n request_opened_at: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'opened_at',\n expr: 'isNull(caller) ? coalesce(item.opened_at, now) : now',\n },\n // Every request starts as `open` (a server import may set another status).\n request_status_default: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'status',\n expr: \"isNull(caller) ? coalesce(item.status, 'open') : 'open'\",\n },\n // A client never writes the notifier's mark or a resolved stamp.\n request_create_rules: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'validate',\n expr: 'isNull(caller) || (isNull(item.notified_status) && isNull(item.resolved_at))',\n message: 'a new request is not resolved or notified yet',\n },\n // A client edits only the requests it opened; staff (the workspace's\n // admins and owners) edit any. `caller.org.role` is the ACTIVE\n // workspace role from the verified session. Deletes run no hooks: any\n // member can delete a request in its workspace (see the README).\n request_edit_own: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n \"isNull(caller) || caller.org.role == 'admin' || caller.org.role == 'owner'\"\n + ' || before.requester == caller.endUserId',\n message: 'only the requester or agency staff edit a request',\n },\n // Only staff move a request. Nobody signed in rewrites who opened it,\n // when, or the notifier's mark (a rewritten requester would be mailed).\n request_status_staff: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'isNull(caller) || (item.notified_status == before.notified_status'\n + ' && item.requester == before.requester && item.opened_at == before.opened_at'\n + \" && (item.status == before.status || caller.org.role == 'admin' || caller.org.role == 'owner'))\",\n message: 'only agency staff change the status of a request; requester and opened_at are fixed',\n },\n // Stamp when a request is resolved; reopening keeps the last stamp. A\n // signed-in caller can never set it by hand.\n request_resolved_at: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'derive',\n field: 'resolved_at',\n expr:\n \"item.status == 'resolved' && before.status != 'resolved' ? now\"\n + ' : (isNull(caller) ? item.resolved_at : before.resolved_at)',\n },\n },\n // A live board per client: each change publishes to org:<client>:requests,\n // which only sessions in that client's workspace can subscribe to.\n cdc: {\n requests_live: { collection: 'requests', channel: 'requests:{org}', events: ['created', 'updated'], payload: 'ids' },\n },\n },\n\n realtime: {},\n\n files: {\n // Shared workspace files (upload with `org: true`). Every member reads\n // them and may upload; deleting needs owner/admin or being the uploader.\n orgScope: { writeRoles: ['owner', 'admin'] },\n },\n\n // The portal's mail. `mock` needs no account. With a real provider, verify\n // the WHOLE fromEmail domain (not one address): every client's portal sends\n // from its own local part on that domain.\n notifications: {\n provider: 'mock',\n fromEmail: 'portal@clients.agency.example',\n fromName: 'Client portal',\n inboxEnabled: true,\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n requests: {\n singular: 'request',\n // WORKSPACE-SCOPED: a client sees its own requests only, and nobody\n // outside the workspace sees them. No writeRoles: every member may open\n // requests; the hooks above confine edits and status moves.\n orgField: 'workspace',\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n status: {\n type: 'string',\n indexSlot: 's2',\n validation: { enum: ['open', 'in_progress', 'waiting_on_client', 'resolved'] },\n },\n requester: { type: 'string', indexSlot: 's3' }, // the member who opened it (stamped)\n priority: { type: 'string', indexSlot: 's4', validation: { enum: ['low', 'normal', 'high', 'urgent'] } },\n opened_at: { type: 'datetime', indexSlot: 't1' },\n resolved_at: { type: 'datetime', indexSlot: 't2' },\n title: { type: 'string', required: true, validation: { max: 160 } },\n body: { type: 'text' },\n attachments: { type: 'json' }, // ids of shared workspace files\n // the last status the notifier mailed about (written by the function only)\n notified_status: { type: 'string' },\n },\n },\n },\n },\n\n functions: {\n // THE NOTIFIER. Woken by every committed create and update of a request.\n // New request \u2192 the client's staff (its admins and owners). Status change \u2192\n // the requester. Mail goes out as THAT client's portal (per-send sender).\n 'notify-request': {\n entry: './functions/notify-request.ts',\n trigger: { kind: 'cmsHook', collection: 'requests', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n});\n",
21160
- "readme": "# Client Portal (support)\n\nThe **agency or B2B portal**: one workspace per client. The client's people sign in to a portal that shows\nonly their own requests and shared files. Your staff work across every client they serve. Every mail looks\nlike it comes from that client's own portal.\n\n```bash\nmkdir my-portal && cd my-portal\nvxil init --template client-portal\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # the collection, hooks, change feed and the notifier; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files and workspace change feeds (`{org}` channels) need `@vxil/cli` 0.26.0 and\n> `@vxil/config` 0.19.0 or later to push from code, and `@vxil/sdk` 0.26.0 or later in the app. `activeOrg`\n> needs `@vxil/sdk` 0.25.0 or later.\n>\n> **Plan note.** The notifier deploys on the Free plan when the project's workload is `staging` or\n> `development`. On a Free `production` project, upgrade to Developer, or push with `--skip-functions` (the\n> portal then works without mail).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One workspace per client | `features.orgs` + `auth.orgClaims: { enabled: true, activeOrg: true }` | a client's people are members of that client only; a staff member in several clients switches with `vx.auth.sessions.switchOrg` |\n| Client-scoped requests | `orgField: 'workspace'` on `requests` | a client reads and opens its own requests; another client's request is a `404` |\n| Staff move the status, clients do not | the `request_status_staff` hook on `caller.org.role` | a client opens requests and edits the text of its own (`request_edit_own`); only the workspace's `admin`s and `owner`s change a status. Deletes run no hooks, so any member can delete a request in its workspace \u2014 route deletes through a function if that matters to you |\n| The requester is who signed in | the `request_requester` derive hook | `requester` (and `opened_at`) come from the verified session and the clock, never from the request body, and no signed-in caller can change them later \u2014 the notifier mails whoever `requester` names |\n| Shared files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | every member of that client reads and uploads; only staff (or the uploader) delete |\n| A live board per client | `features.cms.cdc` with `channel: 'requests:{org}'` | each change goes to `org:<client>:requests`, which only that client's sessions can subscribe to |\n| Mail from the client's portal | `from_name` / `from_local_part` on each send, in `functions/notify-request.ts` | \"Acme portal\" <acme@clients.agency.example>, on your one verified domain |\n\n## Onboarding a client\n\nMemberships are server-only in this mode: an end-user session cannot create workspaces or add members\n(`403 server_only`). That fits a portal, because you onboard clients. From your backend, or with `vxil api`\nand a server key:\n\n```bash\n# the client's workspace; your account lead owns it\nvxil api POST /v1/orgs --data '{\"slug\":\"acme\",\"name\":\"Acme\",\"owner_user_id\":\"<account lead user id>\"}'\n# your staff on this client\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<staff user id>\",\"role\":\"admin\"}'\n# the client's people: an e-mail invitation as a viewer (vxil sends a best-effort mail; the token comes back once)\nvxil api POST /v1/orgs/org_\u2026/invitations --data '{\"email\":\"dana@acme.com\",\"role\":\"viewer\"}'\n```\n\nThe client accepts from your server, which checks who is accepting:\n`POST /v1/orgs/invitations/accept { token, user_id }` with a server key. Seat them only if the signed-in\nuser's verified e-mail is the invited one. A server-key accept does not compare the addresses for you.\n`orgs.maxMembersPerOrg` (25) caps people per client, staff included.\n\n## The portal\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// a client's people have one workspace; staff pick the client they are working on\nconst clients = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(clients[0]!.org_id);\n\n// a client opens a request (workspace, requester, status and opened_at are filled in for them)\nconst r = await vx.from('requests').create({ title: 'Update the pricing page', priority: 'high', body: '\u2026' });\n\n// the board: this client's open requests, newest first\nconst open = await vx.from('requests').query({ filter: { status: { $in: ['open', 'in_progress', 'waiting_on_client'] } }, sort: '-opened_at' });\n\n// staff move it (a client doing this gets 422 \"only agency staff change the status of a request; requester and opened_at are fixed\")\nawait vx.from('requests').patch(r.item_id, { status: 'in_progress' });\n```\n\nAttach a file: upload it into the workspace (`vx.files.createUploadUrl({ org: true, filename, content_type,\nsize_bytes })`, PUT the bytes, `vx.files.complete(object_id)`), then put its id in the request's `attachments`.\n`vx.files.list({ scope: 'org' })` lists everything the client has shared.\n\nFor the live board, open a realtime socket on `org:<client id>:requests` with the user's session and re-read\nthe request a frame names. A token for another client's channel is `403 org_channel_forbidden`.\n\n## The notifier\n\n`notify-request` wakes on every committed create and update of a request.\n\n- **A new request** is mailed to that client's staff (its `admin`s and `owner`s, minus the requester).\n- **A status change** is mailed to the requester.\n- **Every mail is sent as the client's portal.** `from_name` is the workspace name plus \"portal\".\n `from_local_part` is its slug. The domain is always your config `fromEmail` domain, so a send can never\n come from another domain. With a real provider, verify the **whole** domain, not one address, or the sends\n fail at the provider (`vx.notifications.senderDomain()` checks it).\n\nThe function marks each request with the status it mailed about (`notified_status`). Its own write wakes it\nonce more and finds nothing to do. A redelivered event is caught by the same mark and by a per-recipient\nidempotency key. Staff-only hooks let only your backend write the mark. If no mail was accepted, the mark\nstays unset, so the next write to that request tries again. Characters a sender name cannot carry\n(`( ) < > [ ] : ; @ \\ , \"`) are replaced by spaces.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The client filter is one more indexed condition in the same query.\n- **Per request created:** two function invocations (the mail, then the no-op after its own mark), one batch\n send of at most 25 items (`maxMembersPerOrg`), three reads and one write.\n- **Per status change:** two invocations, one send, three reads and one write. Text edits and other updates\n wake the function, which reads the row and stops.\n- **Functions** on a Free `staging` project run inside 10,000 invocations and 500,000 CPU-ms a day. A\n production project runs functions from Developer up.\n- **Realtime sockets per project:** 100 on Free, 1,000 on Developer, 5,000 on Team. **Files** count towards\n the project's storage quota (Free 10 GiB, Developer 25 GiB, Team 150 GiB).\n\n## What to learn from this\n\n- **A workspace is a customer boundary, not only a team.** One workspace per client gives every client a\n sealed portal from one project and one schema.\n- **Role rules that depend on the row live in hooks.** `caller.org.role` is the active workspace role, so\n \"clients edit their own, staff may move any\" is two expressions, enforced in the write.\n- **The sender belongs to the send.** One verified domain, one local part per client, no per-client mail\n setup.\n\n**Pairs with:** **helpdesk** (requester-owned tickets with an SLA sweep, without workspaces) and\n**team-saas** (workspaces your users create and manage themselves, with workspace billing).\n",
21303
+ "readme": "# Client Portal (support)\n\nThe **agency or B2B portal**: one workspace per client. The client's people sign in to a portal that shows\nonly their own requests and shared files. Your staff work across every client they serve. Every mail looks\nlike it comes from that client's own portal.\n\n```bash\nmkdir my-portal && cd my-portal\nvxil init --template client-portal\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # the collection, hooks, change feed and the notifier; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files and workspace change feeds (`{org}` channels) need `@vxil/cli` 0.26.0 and\n> `@vxil/config` 0.19.0 or later to push from code, and `@vxil/sdk` 0.26.0 or later in the app. `activeOrg`\n> needs `@vxil/sdk` 0.25.0 or later.\n>\n> **Plan note.** The notifier deploys on the Free plan when the project's workload is `staging` or\n> `development`. On a Free `production` project, upgrade to Developer, or push with `--skip-functions` (the\n> portal then works without mail).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One workspace per client | `features.orgs` + `auth.orgClaims: { enabled: true, activeOrg: true }` | a client's people are members of that client only; a staff member in several clients switches with `vx.auth.sessions.switchOrg` |\n| Client-scoped requests | `orgField: 'workspace'` on `requests` | a client reads and opens its own requests; another client's request is a `404` |\n| Staff move the status, clients do not | the `request_status_staff` hook on `caller.org.role` | a client opens requests and edits the text of its own (`request_edit_own`); only the workspace's `admin`s and `owner`s change a status. Deletes run no hooks, so any member can delete a request in its workspace \u2014 route deletes through a function if that matters to you |\n| The requester is who signed in | the `request_requester` derive hook | `requester` (and `opened_at`) come from the verified session and the clock, never from the request body, and no signed-in caller can change them later \u2014 the notifier mails whoever `requester` names |\n| Shared files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | every member of that client reads and uploads; only staff (or the uploader) delete |\n| A live board per client | `features.cms.cdc` with `channel: 'requests:{org}'` | each change goes to `org:<client>:requests`, which only that client's sessions can subscribe to |\n| Mail from the client's portal | `from_name` / `from_local_part` on each send, in `functions/notify-request.ts` | \"Acme portal\" <acme@clients.agency.example>, on your one verified domain |\n\n## Onboarding a client\n\nMemberships are server-only in this mode: an end-user session cannot create workspaces or add members\n(`403 server_only`). That fits a portal, because you onboard clients. From your backend, or with `vxil api`\nand a server key:\n\n```bash\n# the client's workspace; your account lead owns it\nvxil api POST /v1/orgs --data '{\"slug\":\"acme\",\"name\":\"Acme\",\"owner_user_id\":\"<account lead user id>\"}'\n# your staff on this client\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<staff user id>\",\"role\":\"admin\"}'\n# the client's people: an e-mail invitation as a viewer (vxil sends a best-effort mail; the token comes back once)\nvxil api POST /v1/orgs/org_\u2026/invitations --data '{\"email\":\"dana@acme.com\",\"role\":\"viewer\"}'\n```\n\nThe client accepts from your server, which checks who is accepting:\n`POST /v1/orgs/invitations/accept { token, user_id }` with a server key. Seat them only if the signed-in\nuser's verified e-mail is the invited one. A server-key accept does not compare the addresses for you.\n`orgs.maxMembersPerOrg` (25) caps people per client, staff included.\n\n## The portal\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// a client's people have one workspace; staff pick the client they are working on\nconst clients = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(clients[0]!.org_id);\n\n// a client opens a request (workspace, requester, status and opened_at are filled in for them)\nconst r = await vx.from('requests').create({ title: 'Update the pricing page', priority: 'high', body: '\u2026' });\n\n// the board: this client's open requests, newest first\nconst open = await vx.from('requests').query({ filter: { status: { $in: ['open', 'in_progress', 'waiting_on_client'] } }, sort: '-opened_at' });\n\n// staff move it (a client doing this gets 422 \"only agency staff change the status of a request; requester and opened_at are fixed\")\nawait vx.from('requests').patch(r.item_id, { status: 'in_progress' });\n```\n\nAttach a file: upload it into the workspace (`vx.files.createUploadUrl({ org: true, filename, content_type,\nsize_bytes })`, PUT the bytes, `vx.files.complete(object_id)`), then put its id in the request's `attachments`.\n`vx.files.list({ scope: 'org' })` lists everything the client has shared.\n\nFor the live board, open a realtime socket on `org:<client id>:requests` with the user's session and re-read\nthe request a frame names. A token for another client's channel is `403 org_channel_forbidden`.\n\n## The notifier\n\n`notify-request` wakes on every committed create and update of a request.\n\n- **A new request** is mailed to that client's staff (its `admin`s and `owner`s, minus the requester).\n- **A status change** is mailed to the requester.\n- **Every mail is sent as the client's portal.** `from_name` is the workspace name plus \"portal\".\n `from_local_part` is its slug. The domain is always your config `fromEmail` domain, so a send can never\n come from another domain. With a real provider, verify the **whole** domain, not one address, or the sends\n fail at the provider (`vx.notifications.senderDomain()` checks it).\n\nThe function marks each request with the status it mailed about (`notified_status`). Its own write wakes it\nonce more and finds nothing to do. A redelivered event is caught by the same mark and by a per-recipient\nidempotency key. Staff-only hooks let only your backend write the mark. If no mail was accepted, the mark\nstays unset, so the next write to that request tries again. Characters a sender name cannot carry\n(`( ) < > [ ] : ; @ \\ , \"`) are replaced by spaces.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The client filter is one more indexed condition in the same query.\n- **Per request created:** two function invocations (the mail, then the no-op after its own mark), one batch\n send of at most 25 items (`maxMembersPerOrg`), three reads and one write.\n- **Per status change:** two invocations, one send, three reads and one write. Text edits and other updates\n wake the function, which reads the row and stops.\n- **Functions** on a Free `staging` project run inside 10,000 invocations and 500,000 CPU-ms a day. A\n production project runs functions from Developer up.\n- **Realtime sockets per project:** 500 on Free, 1,000 on Developer, 5,000 on Team. **Files** count towards\n the project's storage quota (Free 10 GiB, Developer 25 GiB, Team 150 GiB).\n\n## What to learn from this\n\n- **A workspace is a customer boundary, not only a team.** One workspace per client gives every client a\n sealed portal from one project and one schema.\n- **Role rules that depend on the row live in hooks.** `caller.org.role` is the active workspace role, so\n \"clients edit their own, staff may move any\" is two expressions, enforced in the write.\n- **The sender belongs to the send.** One verified domain, one local part per client, no per-client mail\n setup.\n\n**Pairs with:** **helpdesk** (requester-owned tickets with an SLA sweep, without workspaces) and\n**team-saas** (workspaces your users create and manage themselves, with workspace billing).\n",
21161
21304
  "functions": {
21162
21305
  "notify-request.ts": "// notify-request.ts \u2014 the portal's NOTIFIER (a vxil function).\n//\n// Trigger: cmsHook on `requests` (`beforeWrite`: every committed create and\n// update). The payload carries only { event, collection, item_id }, so the\n// function RE-FETCHES the row. It runs as your backend (a cms-hook delivery has\n// no user), so it reads any workspace's row and its members.\n//\n// new (not mailed) \u2192 mail the client's STAFF (the workspace's admins and\n// owners), except the requester\n// status changed \u2192 mail the REQUESTER (\"your request is now \u2026\")\n// anything else \u2192 nothing\n//\n// The mail goes out AS THAT CLIENT'S PORTAL: a per-send sender\n// from_name: \"Acme portal\", from_local_part: \"acme\" \u2192 \"Acme portal\" <acme@<your fromEmail domain>>\n// The domain is always your config `fromEmail` domain (a send can never come\n// from another domain), so verify the WHOLE domain at your mail provider.\n//\n// `notified_status` on the row is the dedupe mark: after mailing, the function\n// writes the status it mailed about. That write wakes the function once more,\n// and it finds nothing to do. Delivery is at-least-once, so every send also\n// carries an idempotency key derived from the delivery's own key.\n//\n// Bounds per delivery: 3 reads, 1 batch send of at most orgs.maxMembersPerOrg\n// (25) items, 1 write.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface Request_ {\n workspace?: string;\n status?: string;\n requester?: string;\n title?: string;\n notified_status?: string;\n}\nconst STATUS_WORDS: Record<string, string> = {\n open: 'open',\n in_progress: 'in progress',\n waiting_on_client: 'waiting on you',\n resolved: 'resolved',\n};\n/** The portal link in the mail. Replace with your app's page. */\nconst PORTAL_URL = 'https://portal.example.com/requests/';\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const { cms, orgs, notifications } = env.scoped_jwts ?? {};\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'requests' || !cms || !orgs || !notifications || !itemId) {\n return Response.json({ skipped: true });\n }\n const auth = (t: string) => ({ authorization: `Bearer ${t}`, 'content-type': 'application/json' });\n\n // 1) the row as it is NOW (a later delivery may already have moved it)\n const res = await fetch(`${base}/v1/cms/items/requests/${encodeURIComponent(itemId)}`, { headers: auth(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const row = ((await res.json()) as { data?: { data?: Request_ } }).data?.data ?? {};\n const status = row.status ?? 'open';\n if (!row.workspace) return Response.json({ skipped: true, reason: 'no workspace' });\n // Already mailed about this status (the write below, or a redelivery).\n if (row.notified_status === status) return Response.json({ skipped: true, reason: 'already notified' });\n // Never mailed yet (no mark) = the NEW-REQUEST mail, whichever delivery\n // gets here first (the create, or an early edit if that mail failed).\n const isNew = !row.notified_status;\n\n // 2) the client workspace: its name and slug become this send's sender\n const orgRes = await fetch(`${base}/v1/orgs/${encodeURIComponent(row.workspace)}`, { headers: auth(orgs) });\n if (!orgRes.ok) return Response.json({ skipped: true, reason: `org ${orgRes.status}` });\n const org = ((await orgRes.json()) as { data?: { name?: string; slug?: string } }).data ?? {};\n const clientName = (org.name ?? 'Client').trim() || 'Client';\n // A sender name refuses control/bidi characters and ( ) < > [ ] : ; @ \\ , \"\n // (422 per item), so the name is cleaned to what the provider header allows.\n const senderName = `${clientName} portal`.replace(/[\\p{Cc}\\u2028\\u2029\\u202A-\\u202E\\u2066-\\u2069()<>[\\]:;@\\\\,\"]/gu, ' ')\n .replace(/\\s+/g, ' ').trim().slice(0, 64);\n const sender = {\n ...(senderName ? { from_name: senderName } : {}),\n // org slugs are a-z, 0-9 and '-', a subset of the local-part alphabet\n ...(org.slug ? { from_local_part: org.slug.slice(0, 40) } : {}),\n };\n\n // 3) who hears about it\n let recipients: string[] = [];\n let subject: string;\n let paragraph: string;\n if (isNew) {\n const memRes = await fetch(`${base}/v1/orgs/${encodeURIComponent(row.workspace)}/members`, { headers: auth(orgs) });\n const members = memRes.ok\n ? (((await memRes.json()) as { data?: { members?: Array<{ user_id: string; role: string }> } }).data?.members ?? [])\n : [];\n recipients = members\n .filter((m) => (m.role === 'admin' || m.role === 'owner') && m.user_id !== row.requester)\n .map((m) => m.user_id);\n subject = `New request from ${clientName}: ${row.title ?? itemId}`;\n paragraph = `${clientName} opened a request${row.title ? ` \"${row.title}\"` : ''}. Open the portal to pick it up.`;\n } else {\n if (row.requester) recipients = [row.requester];\n subject = `Your request is ${STATUS_WORDS[status] ?? status}`;\n paragraph = `\"${row.title ?? itemId}\" is now ${STATUS_WORDS[status] ?? status}.`;\n }\n\n let accepted = 0;\n if (recipients.length) {\n const send = await fetch(`${base}/v1/notifications/send/batch`, {\n method: 'POST',\n headers: auth(notifications),\n body: JSON.stringify({\n items: recipients.slice(0, 100).map((user_id) => ({\n user_id,\n template: 'transactional',\n channel: 'both', // e-mail + the in-app inbox\n data: { subject, paragraph, cta_label: 'Open the request', cta_url: `${PORTAL_URL}${itemId}` },\n ...sender,\n // the new-request mail is keyed on the request (two racing deliveries\n // send it once); a status mail on this delivery\n idempotency_key: (isNew ? `new:${itemId}:${user_id}` : `${env.idempotency_key}:${status}:${user_id}`).slice(0, 255),\n })),\n }),\n });\n // A batch answers 202 even when items fail (each item is its own result),\n // so success is the accepted count, not the status.\n accepted = send.ok ? (((await send.json()) as { data?: { accepted?: number } }).data?.accepted ?? 0) : 0;\n // Nothing went out: leave the mark unset, so the next write to this\n // request tries again. (This answer is recorded, not re-delivered.)\n if (accepted === 0) return Response.json({ notified: 0, status: send.status }, { status: 502 });\n }\n\n // 4) the dedupe mark (a server write: the staff-only hooks let it through)\n await fetch(`${base}/v1/cms/items/requests/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: auth(cms),\n body: JSON.stringify({ data: { notified_status: status } }),\n });\n return Response.json({ notified: accepted, recipients: recipients.length, status });\n },\n};\n"
21163
21306
  }
@@ -21229,7 +21372,7 @@ export default defineConfig({
21229
21372
  "hasFunctions": true,
21230
21373
  "byoKeys": [],
21231
21374
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Team SaaS\" \u2014 the canonical B2B team product: every customer gets WORKSPACES,\n// each workspace shares its own folders, docs and files, sees its own live\n// changes, and carries its own plan. Declared end-to-end in ONE typed file.\n//\n// \u2022 auth \u2192 the session acts in ONE active workspace (orgClaims.activeOrg);\n// the app switches with vx.auth.sessions.switchOrg(orgId)\n// \u2022 orgs \u2192 workspaces + memberships; roles owner > admin > editor > viewer\n// (`editor` is a CUSTOM role declared below)\n// \u2022 cms \u2192 `folders` (a parent self-relation) and `docs`, both\n// WORKSPACE-SCOPED (orgField): members share rows, nobody else\n// sees them; one change feed per workspace ('docs:{org}')\n// \u2022 files \u2192 workspace-owned files (upload with `org: true`) that survive\n// the uploader's erasure\n// \u2022 payments \u2192 a payments INTEGRATION (your own provider account; vxil never\n// in the flow of funds) with the WORKSPACE as the billing subject:\n// a `free` tier every new workspace gets, and a paid `team` tier\n// \u2022 functions \u2192 the membership changes (server-only in this mode) and the\n// team checkout, each behind a role check of the caller\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n methods: { emailPassword: true, magicLink: true },\n // ACTIVE-ORG MODE. The session carries ONE chosen workspace and keeps it:\n // the first sign-in picks the most recent membership, switchOrg moves it,\n // a refresh never falls back to another workspace. The workspace role\n // gates ONLY workspace-scoped collections \u2014 owning a workspace never\n // opens an unscoped one. Turn it on before end users sign in.\n orgClaims: { enabled: true, activeOrg: true },\n // A membership change reaches live sessions in about 60 s; if that notice\n // cannot be delivered, the access-token TTL is the bound \u2014 keep it short.\n session: { ttlMinutes: 15, refreshTtlDays: 30 },\n },\n\n orgs: {\n enabled: true,\n // One project serves many customer workspaces. The org cap is a count of\n // workspaces in THIS project (schema max 100,000); raise it as you grow.\n maxOrgsPerTenant: 1000,\n // The hard seat ceiling per workspace; the PLAN's seat quota (payments\n // below) is the commercial one the membership function enforces.\n maxMembersPerOrg: 50,\n invitationTtlHours: 72,\n // The built-ins are owner > admin > member > viewer. `editor` is ours: a\n // plain role key the cms gates below name. The permission strings are\n // yours too \u2014 vxil only answers whether a member holds one.\n roles: [\n { role_key: 'editor', name: 'Editor', permissions: ['docs.write', 'folders.write'], rank: 2 },\n ],\n },\n\n cms: {\n // Docs are live the moment they are written \u2014 a workspace tool, not a CMS.\n draftPublish: false,\n // ONE CHANGE FEED PER WORKSPACE. `{org}` resolves per row from the\n // collection's orgField: a change to a doc of workspace W publishes to\n // `org:W:docs`, and only a session whose ACTIVE workspace is W can mint a\n // token for that channel. Frames carry ids only \u2014 the client re-reads the\n // row, and the workspace rule applies there.\n cdc: {\n docs_live: { collection: 'docs', channel: 'docs:{org}', events: ['created', 'updated', 'deleted'], payload: 'ids' },\n folders_live: { collection: 'folders', channel: 'folders:{org}', events: ['created', 'updated', 'deleted'], payload: 'ids' },\n },\n },\n\n realtime: {}, // the sockets the change feeds above publish to\n\n files: {\n // WORKSPACE FILES. Any member of the active workspace reads a file the\n // workspace owns; deleting it (and its TTL, OCR, shared links) needs one\n // of these roles or being the uploader. The workspace `owner` always passes.\n orgScope: { writeRoles: ['owner', 'admin', 'editor'] },\n },\n\n // A payments INTEGRATION: the tenant's own Stripe/Paddle/PayPal account;\n // vxil records what the provider reports and is never in the flow of funds.\n // `mock` runs everything with no account. Go live with your provider block,\n // e.g. paddle: { apiKeyRef: 'paddle_api_key', webhookSecretRef: 'paddle_webhook' }.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {},\n // The workspace's plan. The higher `rank` wins when a workspace holds\n // both: `free` is granted once when the workspace is created (by the\n // membership function), `team` comes from the provider subscription.\n tierMap: {\n free: { entitlements: ['workspace'], quotas: { seats: 3 }, rank: 0 },\n team: { entitlements: ['workspace', 'team'], quotas: { seats: 50 }, rank: 10 },\n },\n // your provider's price id \u2192 tier (replace with the real one)\n priceMap: { pri_team_monthly: 'team' },\n },\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // A folder needs no access control of its own: it is a row of the\n // workspace, so every member sees the whole tree and `writeRoles` decides\n // who may change it. Nest with `parent` (null = a root folder).\n folders: {\n singular: 'folder',\n orgField: 'workspace', // stamped with the session's active workspace on create\n writeRoles: ['editor', 'admin'], // matched against the ACTIVE-workspace role; `owner` always passes\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n // `restrict`: a folder that still has sub-folders cannot be deleted \u2014\n // delete a tree bottom-up (guide ch. 4, \"Folders in a workspace\").\n parent: { type: 'relation', relationTo: 'folders', onDelete: 'restrict', indexSlot: 's2' },\n // \"/<rootId>/<childId>/\u2026\" \u2014 lists a whole subtree in one\n // `{ path: { $startsWith } }` query. Nothing on the server sets it or\n // stops a cycle: your app (or a function) writes `path` and, on a\n // move, refuses a `parent` whose path starts with the folder's own.\n path: { type: 'string', indexSlot: 's3' },\n name: { type: 'string', required: true, indexSlot: 's4' },\n position: { type: 'float', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n\n docs: {\n singular: 'doc',\n orgField: 'workspace',\n writeRoles: ['editor', 'admin'], // viewers read, editors and admins write\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n // a doc can only be filed in a folder of the SAME workspace\n // (422 relation_not_in_org otherwise), and a folder holding docs is\n // not deletable until they move\n folder: { type: 'relation', relationTo: 'folders', onDelete: 'restrict', indexSlot: 's2' },\n title: { type: 'string', required: true, indexSlot: 's3' },\n author: { type: 'string', indexSlot: 's4' }, // the member who wrote it (display only)\n word_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n attachments: { type: 'json' }, // ids of workspace files (vx.files.createUploadUrl({ org: true, \u2026 }))\n },\n },\n },\n },\n\n // \u2500\u2500 The steps that are not config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // MEMBERSHIP CHANGES. In active-org mode an end-user session cannot write\n // orgs at all (403 server_only), because a membership IS the data boundary.\n // The app calls this function AS the signed-in user; it checks the caller's\n // role with the caller's own token (the verified identity, never the\n // payload), then writes with `server_jwts`, tokens not bound to the user.\n // Actions: create \xB7 invite \xB7 accept \xB7 set_role \xB7 remove.\n 'workspace-members': {\n entry: './functions/workspace-members.ts',\n trigger: { kind: 'http', serverScopes: ['orgs:read', 'orgs:write', 'payments:read', 'payments:write'] },\n scopes: ['orgs:read', 'orgs:write', 'payments:read', 'payments:write'],\n egressAllow: [], // nothing external; it only talks back to your own backend\n signature: {\n // the values are TypeScript types, emitted verbatim by `vxil gen`\n input: {\n action: \"'create' | 'invite' | 'accept' | 'set_role' | 'remove'\",\n org_id: 'string | undefined',\n slug: 'string | undefined',\n name: 'string | undefined',\n role: 'string | undefined',\n user_id: 'string | undefined',\n token: 'string | undefined',\n },\n },\n },\n\n // THE TEAM CHECKOUT. A workspace admin opens a provider checkout whose\n // SUBJECT is the workspace (`org_id`), so the plan belongs to the workspace,\n // not to whoever paid, and survives an ownership hand-over.\n 'workspace-billing': {\n entry: './functions/workspace-billing.ts',\n trigger: { kind: 'http', serverScopes: ['payments:write'] },\n scopes: ['orgs:read', 'payments:write'],\n egressAllow: [],\n signature: {\n input: { org_id: 'string', success_url: 'string | undefined', cancel_url: 'string | undefined' },\n output: '{ url: string }',\n },\n },\n },\n});\n",
21232
- "readme": "# Team SaaS (saas)\n\nThe canonical **B2B team product**. Each customer gets one or more **workspaces**. A workspace shares its\nown folders, docs and files, sees its own live changes, and carries its own plan. A signed-in user acts in\n**one active workspace** at a time and switches between them like in any team tool.\n\nEverything a \"team SaaS\" usually builds by hand around its tables is declared here instead: the workspace\nboundary on every read and write, roles inside a workspace, a change feed per workspace, workspace-owned\nfiles and a plan per workspace. The two steps that need product rules (who may invite, change roles or buy\nthe plan) are two small functions.\n\n```bash\nmkdir my-team-app && cd my-team-app\nvxil init --template team-saas\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections, roles, plans, change feeds, both functions; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files, workspace change feeds (`{org}` channels) and the client half of this\n> blueprint need `@vxil/cli` 0.26.0 and `@vxil/config` 0.19.0 or later (older versions refuse the `{org}`\n> channel when they validate locally), and the app needs `@vxil/sdk` 0.26.0 or later. `activeOrg` itself\n> needs `@vxil/sdk` 0.25.0 or later in the app: older SDKs keep getting `401 session_stale` after a membership\n> change until the access token expires.\n>\n> **Plan note.** The two functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload` step above). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or push with\n> `--skip-functions` (the workspace data model works without them, but membership changes then need your own\n> server).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One active workspace per session | `features.auth.orgClaims: { enabled: true, activeOrg: true }` | the session carries one chosen workspace; `vx.auth.sessions.switchOrg(orgId)` moves it; a refresh never silently falls back to another workspace |\n| Roles inside a workspace | `features.orgs.roles` (`editor`) + the built-in `owner`, `admin`, `viewer` | `vxil push` creates `editor`; the workspace role gates only workspace collections, so owning a workspace never opens anything else |\n| Workspace-scoped rows | `orgField: 'workspace'` on `folders` and `docs` | members share the rows; another workspace's row is a `404`; a create is stamped with the active workspace |\n| Who may write | `writeRoles: ['editor', 'admin']` | viewers read, editors and admins write; the workspace `owner` always passes |\n| Folders and sub-folders | `folders.parent` (a relation to `folders`) + `path` | list a folder with `{ parent: id }`, the root with `{ parent: null }`, a subtree with `{ path: { $startsWith } }`. The server does not maintain `path` or prevent cycles: your app writes `path`, and on a move refuses a new `parent` whose `path` starts with the moved folder's own |\n| A change feed per workspace | `features.cms.cdc` with `channel: 'docs:{org}'` | each change goes to `org:<workspace id>:docs`, and only a session whose active workspace that is can subscribe |\n| Workspace files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | any member reads them; they stay when the uploader's account is erased; deleting the workspace deletes them |\n| The workspace pays | `features.payments.ledger.tierMap` (`free`, `team`) + `org_id` on the checkout | the plan's subject is `org:<workspace id>`: every member reads it, and an ownership hand-over changes nothing |\n| Membership changes | `functions.workspace-members` with `serverScopes` | the caller's role is checked with the caller's own token, then the write is made as your backend |\n\n## The workspace flow in the app\n\nBuild the client with the **whole session pair**, so the SDK can rotate it on `switchOrg` and on\n`session_stale`:\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// `pair` is the { token, refresh_token, \u2026 } session from sign-in; `save` persists it (a cookie, storage)\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// 1) a brand-new user creates their first workspace (they become its owner, on the free plan)\nconst ws = await vx.fn['workspace-members']({ action: 'create', slug: 'acme', name: 'Acme' });\nawait vx.auth.sessions.switchOrg(ws.org_id);\n\n// 2) the workspace switcher: the user's workspaces, each with their role\nconst mine = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(mine[0]!.org_id);\nvx.auth.session.org; // \u2192 { id, role, perms } of the active workspace\n\n// 3) everything below acts in the active workspace, with no workspace id in the call\nconst root = await vx.from('folders').create({ name: 'Specs' });\nawait vx.from('docs').create({ title: 'Kickoff', folder: root.item_id, body: '\u2026' });\nconst inRoot = await vx.from('docs').query({ filter: { folder: root.item_id } });\n```\n\nThe `workspace` field is stamped for you. Naming another workspace is `403 other_org`. A session with no\nactive workspace gets `403 org_required` (send the user to the switcher). A doc filed in another\nworkspace's folder is `422 relation_not_in_org`.\n\n**Removed from a workspace?** Within about 60 seconds the user's next request answers `401 session_stale`. The\nSDK refreshes the pair once. The new pair carries the new role, or no workspace at all after a removal (the\nrefresh answer's `org` is `null`: show the switcher). Then it retries the request. An answer with\n`org_unavailable: true` means the workspace service could not be reached: keep the workspace and try again.\nDo not switch on it.\n\n## Inviting a teammate\n\nIn this mode an end-user session cannot write memberships at all (`403 server_only`): a membership decides\nwhich workspace's rows a session reads, so it is a server decision. The app calls the `workspace-members`\nfunction instead. The function checks the caller's role and then acts as your backend:\n\n```ts\n// an admin or owner of the active workspace\nconst { invite_token } = await vx.fn['workspace-members']({ action: 'invite', org_id: ws.org_id, role: 'editor' });\n// build your own accept link and deliver it, e.g. https://app.example.com/join?token=\u2026\n\n// the invitee, signed in, on that page\nconst joined = await vx.fn['workspace-members']({ action: 'accept', token });\nawait vx.auth.sessions.switchOrg(joined.org_id);\n```\n\n- **Link invites.** The function issues invitations without an e-mail address, so vxil sends nothing and\n anyone signed in who holds the link can take the seat once. The token is returned once and expires after\n `orgs.invitationTtlHours` (72 here). To bind an invitation to one address instead, create it with an `email`\n from your server and check the accepting user's verified address in the function before you accept: a\n server-key accept does not compare the addresses for you.\n- **Seats come from the plan.** An invite is refused with `402 seats_exhausted` when members plus pending\n invitations reach the workspace's `seats` quota (3 on `free`, 50 on `team`). `orgs.maxMembersPerOrg`\n (50) is the hard ceiling behind it.\n- **Roles.** An invite carries `admin`, `editor` or `viewer`. `set_role` changes a member's role (admin or\n owner). Granting or taking `owner` needs an owner, and a workspace always keeps one owner (`409`). `remove`\n removes a member, or lets a user leave (`user_id` = themselves).\n- **Workspaces per user.** `create` refuses a sixth owned workspace (`MAX_OWNED_WORKSPACES` in the function).\n `orgs.maxOrgsPerTenant` (1,000 here, up to 100,000) is shared by every user of the project.\n\n## Live changes in the active workspace\n\n`docs_live` publishes every created, updated or deleted doc of workspace W to `org:W:docs`, with ids only.\nMint a connect token for that channel with the user's session, subscribe, and re-read the row a frame names\n(the workspace rule applies to the re-read):\n\n```ts\nconst channel = `org:${vx.auth.session.org!.id}:docs`;\n// open the socket with vx.realtime.tokenProvider({ user_id }) as the token provider (guide ch. 6, realtime)\n```\n\nA token for another workspace's channel is `403 org_channel_forbidden`. After `switchOrg`, mint a token for\nthe new workspace's channel. A frame is a hint, not the data, so a client that missed one loses nothing it\ncannot re-read.\n\n## Workspace files\n\n```ts\nconst up = await vx.files.createUploadUrl({ org: true, filename: 'brief.pdf', content_type: 'application/pdf', size_bytes: 48_211 });\nawait fetch(up.upload_url, { method: 'PUT', headers: { 'content-type': 'application/pdf' }, body: bytes });\nawait vx.files.complete(up.object_id);\nconst shared = await vx.files.list({ scope: 'org' }); // the active workspace's files\n```\n\nAny member of the workspace can upload into it, read its files and mint download URLs. Deleting a file, its\nTTL, OCR and shared links need `owner`, `admin` or `editor` (`files.orgScope.writeRoles`), or being the\nuploader. Outside its workspace a workspace file is a `404`, for the uploader too. Erasing the uploader keeps\nthe file; deleting the workspace deletes its files in the background.\n\n## Billing the workspace\n\nThe plan is a **payments integration**: your own Stripe, Paddle or PayPal account. vxil records what the\nprovider reports and is never in the flow of funds. This blueprint runs on `mock` until you add your\nprovider block and replace `pri_team_monthly` in `ledger.priceMap` with your real price id.\n\n- **`free`.** `create` grants it once to every new workspace (a manual, open-ended plan whose subject is the\n workspace).\n- **`team`.** A workspace admin opens the checkout through the `workspace-billing` function:\n `const { url } = await vx.fn['workspace-billing']({ org_id })`, then `location.assign(url)`. The checkout's\n subject is the workspace, so the subscription folds onto `org:<workspace id>`. It outranks `free` (`rank`\n 10 against 0).\n- **Reading it.** Any member reads the active workspace's plan:\n `await vx.payments.hasEntitlement({ subject: 'org' }, 'team')`. Your server reads any workspace's plan with\n `{ org_id }`.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The workspace is one more indexed condition in the same query: no added round\n trip, no added read.\n- **A membership change** is one function invocation (at most 4 reads and 2 writes against your own backend,\n no fan-out). The billing function is one read and one write. On a Free `staging` project, functions run\n inside 10,000 invocations and 500,000 CPU-ms a day. A production project runs functions from Developer up.\n- **Change feeds** publish one frame per changed row, ids only. Realtime sockets per project follow your\n plan: 100 on Free, 1,000 on Developer, 5,000 on Team.\n- **Files** count towards the project's storage quota like every file (Free 10 GiB, Developer 25 GiB, Team\n 150 GiB).\n- **Payments.** vxil adds no fee to the money flow. Your provider charges its own fees.\n\n## What to learn from this\n\n- **The workspace boundary is config, not code.** `orgField` puts the workspace predicate on every end-user\n read, write, count, join, cascade and change feed. Your server key is never affected.\n- **A membership is a data boundary, so it changes on the server.** The workspaceGuard pattern is three\n lines: who (`env.end_user`), may they (`/check` with their own token), then act (`server_jwts`).\n- **The workspace is the customer.** It holds the plan, the files and the rows, so people can come and go\n without anything being re-keyed.\n\n**Pairs with:** **team-workspace** (orgs with SSO, field-level security and one action button, without the\nactive workspace) and **ai-knowledge-base** (this workspace model plus search and grounded answers).\n",
21375
+ "readme": "# Team SaaS (saas)\n\nThe canonical **B2B team product**. Each customer gets one or more **workspaces**. A workspace shares its\nown folders, docs and files, sees its own live changes, and carries its own plan. A signed-in user acts in\n**one active workspace** at a time and switches between them like in any team tool.\n\nEverything a \"team SaaS\" usually builds by hand around its tables is declared here instead: the workspace\nboundary on every read and write, roles inside a workspace, a change feed per workspace, workspace-owned\nfiles and a plan per workspace. The two steps that need product rules (who may invite, change roles or buy\nthe plan) are two small functions.\n\n```bash\nmkdir my-team-app && cd my-team-app\nvxil init --template team-saas\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections, roles, plans, change feeds, both functions; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files, workspace change feeds (`{org}` channels) and the client half of this\n> blueprint need `@vxil/cli` 0.26.0 and `@vxil/config` 0.19.0 or later (older versions refuse the `{org}`\n> channel when they validate locally), and the app needs `@vxil/sdk` 0.26.0 or later. `activeOrg` itself\n> needs `@vxil/sdk` 0.25.0 or later in the app: older SDKs keep getting `401 session_stale` after a membership\n> change until the access token expires.\n>\n> **Plan note.** The two functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload` step above). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or push with\n> `--skip-functions` (the workspace data model works without them, but membership changes then need your own\n> server).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One active workspace per session | `features.auth.orgClaims: { enabled: true, activeOrg: true }` | the session carries one chosen workspace; `vx.auth.sessions.switchOrg(orgId)` moves it; a refresh never silently falls back to another workspace |\n| Roles inside a workspace | `features.orgs.roles` (`editor`) + the built-in `owner`, `admin`, `viewer` | `vxil push` creates `editor`; the workspace role gates only workspace collections, so owning a workspace never opens anything else |\n| Workspace-scoped rows | `orgField: 'workspace'` on `folders` and `docs` | members share the rows; another workspace's row is a `404`; a create is stamped with the active workspace |\n| Who may write | `writeRoles: ['editor', 'admin']` | viewers read, editors and admins write; the workspace `owner` always passes |\n| Folders and sub-folders | `folders.parent` (a relation to `folders`) + `path` | list a folder with `{ parent: id }`, the root with `{ parent: null }`, a subtree with `{ path: { $startsWith } }`. The server does not maintain `path` or prevent cycles: your app writes `path`, and on a move refuses a new `parent` whose `path` starts with the moved folder's own |\n| A change feed per workspace | `features.cms.cdc` with `channel: 'docs:{org}'` | each change goes to `org:<workspace id>:docs`, and only a session whose active workspace that is can subscribe |\n| Workspace files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | any member reads them; they stay when the uploader's account is erased; deleting the workspace deletes them |\n| The workspace pays | `features.payments.ledger.tierMap` (`free`, `team`) + `org_id` on the checkout | the plan's subject is `org:<workspace id>`: every member reads it, and an ownership hand-over changes nothing |\n| Membership changes | `functions.workspace-members` with `serverScopes` | the caller's role is checked with the caller's own token, then the write is made as your backend |\n\n## The workspace flow in the app\n\nBuild the client with the **whole session pair**, so the SDK can rotate it on `switchOrg` and on\n`session_stale`:\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// `pair` is the { token, refresh_token, \u2026 } session from sign-in; `save` persists it (a cookie, storage)\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// 1) a brand-new user creates their first workspace (they become its owner, on the free plan)\nconst ws = await vx.fn['workspace-members']({ action: 'create', slug: 'acme', name: 'Acme' });\nawait vx.auth.sessions.switchOrg(ws.org_id);\n\n// 2) the workspace switcher: the user's workspaces, each with their role\nconst mine = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(mine[0]!.org_id);\nvx.auth.session.org; // \u2192 { id, role, perms } of the active workspace\n\n// 3) everything below acts in the active workspace, with no workspace id in the call\nconst root = await vx.from('folders').create({ name: 'Specs' });\nawait vx.from('docs').create({ title: 'Kickoff', folder: root.item_id, body: '\u2026' });\nconst inRoot = await vx.from('docs').query({ filter: { folder: root.item_id } });\n```\n\nThe `workspace` field is stamped for you. Naming another workspace is `403 other_org`. A session with no\nactive workspace gets `403 org_required` (send the user to the switcher). A doc filed in another\nworkspace's folder is `422 relation_not_in_org`.\n\n**Removed from a workspace?** Within about 60 seconds the user's next request answers `401 session_stale`. The\nSDK refreshes the pair once. The new pair carries the new role, or no workspace at all after a removal (the\nrefresh answer's `org` is `null`: show the switcher). Then it retries the request. An answer with\n`org_unavailable: true` means the workspace service could not be reached: keep the workspace and try again.\nDo not switch on it.\n\n## Inviting a teammate\n\nIn this mode an end-user session cannot write memberships at all (`403 server_only`): a membership decides\nwhich workspace's rows a session reads, so it is a server decision. The app calls the `workspace-members`\nfunction instead. The function checks the caller's role and then acts as your backend:\n\n```ts\n// an admin or owner of the active workspace\nconst { invite_token } = await vx.fn['workspace-members']({ action: 'invite', org_id: ws.org_id, role: 'editor' });\n// build your own accept link and deliver it, e.g. https://app.example.com/join?token=\u2026\n\n// the invitee, signed in, on that page\nconst joined = await vx.fn['workspace-members']({ action: 'accept', token });\nawait vx.auth.sessions.switchOrg(joined.org_id);\n```\n\n- **Link invites.** The function issues invitations without an e-mail address, so vxil sends nothing and\n anyone signed in who holds the link can take the seat once. The token is returned once and expires after\n `orgs.invitationTtlHours` (72 here). To bind an invitation to one address instead, create it with an `email`\n from your server and check the accepting user's verified address in the function before you accept: a\n server-key accept does not compare the addresses for you.\n- **Seats come from the plan.** An invite is refused with `402 seats_exhausted` when members plus pending\n invitations reach the workspace's `seats` quota (3 on `free`, 50 on `team`). `orgs.maxMembersPerOrg`\n (50) is the hard ceiling behind it.\n- **Roles.** An invite carries `admin`, `editor` or `viewer`. `set_role` changes a member's role (admin or\n owner). Granting or taking `owner` needs an owner, and a workspace always keeps one owner (`409`). `remove`\n removes a member, or lets a user leave (`user_id` = themselves).\n- **Workspaces per user.** `create` refuses a sixth owned workspace (`MAX_OWNED_WORKSPACES` in the function).\n `orgs.maxOrgsPerTenant` (1,000 here, up to 100,000) is shared by every user of the project.\n\n## Live changes in the active workspace\n\n`docs_live` publishes every created, updated or deleted doc of workspace W to `org:W:docs`, with ids only.\nMint a connect token for that channel with the user's session, subscribe, and re-read the row a frame names\n(the workspace rule applies to the re-read):\n\n```ts\nconst channel = `org:${vx.auth.session.org!.id}:docs`;\n// open the socket with vx.realtime.tokenProvider({ user_id }) as the token provider (guide ch. 6, realtime)\n```\n\nA token for another workspace's channel is `403 org_channel_forbidden`. After `switchOrg`, mint a token for\nthe new workspace's channel. A frame is a hint, not the data, so a client that missed one loses nothing it\ncannot re-read.\n\n## Workspace files\n\n```ts\nconst up = await vx.files.createUploadUrl({ org: true, filename: 'brief.pdf', content_type: 'application/pdf', size_bytes: 48_211 });\nawait fetch(up.upload_url, { method: 'PUT', headers: { 'content-type': 'application/pdf' }, body: bytes });\nawait vx.files.complete(up.object_id);\nconst shared = await vx.files.list({ scope: 'org' }); // the active workspace's files\n```\n\nAny member of the workspace can upload into it, read its files and mint download URLs. Deleting a file, its\nTTL, OCR and shared links need `owner`, `admin` or `editor` (`files.orgScope.writeRoles`), or being the\nuploader. Outside its workspace a workspace file is a `404`, for the uploader too. Erasing the uploader keeps\nthe file; deleting the workspace deletes its files in the background.\n\n## Billing the workspace\n\nThe plan is a **payments integration**: your own Stripe, Paddle or PayPal account. vxil records what the\nprovider reports and is never in the flow of funds. This blueprint runs on `mock` until you add your\nprovider block and replace `pri_team_monthly` in `ledger.priceMap` with your real price id.\n\n- **`free`.** `create` grants it once to every new workspace (a manual, open-ended plan whose subject is the\n workspace).\n- **`team`.** A workspace admin opens the checkout through the `workspace-billing` function:\n `const { url } = await vx.fn['workspace-billing']({ org_id })`, then `location.assign(url)`. The checkout's\n subject is the workspace, so the subscription folds onto `org:<workspace id>`. It outranks `free` (`rank`\n 10 against 0).\n- **Reading it.** Any member reads the active workspace's plan:\n `await vx.payments.hasEntitlement({ subject: 'org' }, 'team')`. Your server reads any workspace's plan with\n `{ org_id }`.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The workspace is one more indexed condition in the same query: no added round\n trip, no added read.\n- **A membership change** is one function invocation (at most 4 reads and 2 writes against your own backend,\n no fan-out). The billing function is one read and one write. On a Free `staging` project, functions run\n inside 10,000 invocations and 500,000 CPU-ms a day. A production project runs functions from Developer up.\n- **Change feeds** publish one frame per changed row, ids only. Realtime sockets per project follow your\n plan: 500 on Free, 1,000 on Developer, 5,000 on Team.\n- **Files** count towards the project's storage quota like every file (Free 10 GiB, Developer 25 GiB, Team\n 150 GiB).\n- **Payments.** vxil adds no fee to the money flow. Your provider charges its own fees.\n\n## What to learn from this\n\n- **The workspace boundary is config, not code.** `orgField` puts the workspace predicate on every end-user\n read, write, count, join, cascade and change feed. Your server key is never affected.\n- **A membership is a data boundary, so it changes on the server.** The workspaceGuard pattern is three\n lines: who (`env.end_user`), may they (`/check` with their own token), then act (`server_jwts`).\n- **The workspace is the customer.** It holds the plan, the files and the rows, so people can come and go\n without anything being re-keyed.\n\n**Pairs with:** **team-workspace** (orgs with SSO, field-level security and one action button, without the\nactive workspace) and **ai-knowledge-base** (this workspace model plus search and grounded answers).\n",
21233
21376
  "functions": {
21234
21377
  "workspace-billing.ts": "// workspace-billing.ts \u2014 open the TEAM checkout for a workspace (a vxil function).\n//\n// Called by the app AS the signed-in user:\n// const { url } = await vx.fn['workspace-billing']({ org_id }) // then location.assign(url)\n//\n// It is a payments INTEGRATION: the checkout is your own provider's (Stripe,\n// Paddle, PayPal); vxil opens it and later records what the provider reports.\n// vxil is never in the flow of funds.\n//\n// The checkout's SUBJECT is the workspace (`org_id`), so the provider event folds\n// onto `org:<org_id>`: the `team` tier belongs to the workspace, every member\n// reads it (`vx.payments.hasEntitlement({ subject: 'org' }, 'team')`), and it\n// stays when the workspace changes owner. The paying member rides along as\n// `user_id` \u2014 tagged on the checkout, never entitled.\n//\n// Only an admin or owner of THAT workspace may open it (checked with the\n// caller's own token, so it is the verified identity and the live membership).\n// The price is fixed here, never taken from the payload. Bounds: one read, one\n// write per call.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = HttpFunctionEnvelope<{ org_id?: string; success_url?: string; cancel_url?: string }>;\n\n/** Your provider's price id for the team plan \u2014 the `priceMap` key in vxil.config.ts. */\nconst TEAM_PRICE = 'pri_team_monthly';\n/** Where the provider sends the payer back. Replace with your app's pages. */\nconst APP_ORIGIN = 'https://app.example.com';\n\nconst fail = (status: number, code: string, message: string): Response =>\n Response.json({ error: { code, message } }, { status });\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const me = env.end_user;\n if (!me || me.anon) return fail(401, 'sign_in_required', 'call this function with a signed-in user session');\n const userOrgs = env.scoped_jwts?.orgs;\n const pay = env.server_jwts?.payments;\n if (!userOrgs || !pay) return fail(500, 'misconfigured', 'orgs:read scope or payments:write serverScope is missing');\n const orgId = String(env.payload?.org_id ?? '');\n if (!orgId) return fail(422, 'validation_failed', 'need { org_id }');\n\n // workspaceGuard: billing is an admin decision in THIS workspace\n const check = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(me.id)}&permission=members:manage`,\n { headers: { authorization: `Bearer ${userOrgs}` } },\n );\n const verdict = ((await check.json().catch(() => ({}))) as { data?: { allowed?: boolean } }).data;\n if (!check.ok || verdict?.allowed !== true) return fail(403, 'forbidden', 'only a workspace admin or owner may change the plan');\n\n // Only your own pages may be return targets.\n const back = (u: unknown, fallback: string): string =>\n typeof u === 'string' && u.startsWith(`${APP_ORIGIN}/`) ? u : fallback;\n\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n // One key per INVOCATION (the runtime's per-call key, or the caller's\n // Idempotency-Key header): a retry of this call replays the same session,\n // and a later purchase gets a fresh one.\n headers: { authorization: `Bearer ${pay}`, 'content-type': 'application/json', 'idempotency-key': env.idempotency_key },\n body: JSON.stringify({\n org_id: orgId, // the workspace gets the plan\n user_id: me.id, // the member paying (tagged, not entitled)\n mode: 'subscription',\n line_items: [{ price_ref: TEAM_PRICE, quantity: 1 }],\n success_url: back(env.payload?.success_url, `${APP_ORIGIN}/billing/ok`),\n cancel_url: back(env.payload?.cancel_url, `${APP_ORIGIN}/billing`),\n }),\n });\n const out = (await sess.json().catch(() => ({}))) as { data?: { url?: string }; error?: { code?: string; message?: string } };\n if (!sess.ok || !out.data?.url) {\n return fail(sess.status >= 400 ? sess.status : 502, out.error?.code ?? 'checkout_failed', out.error?.message ?? 'the provider checkout could not be opened');\n }\n return Response.json({ url: out.data.url });\n },\n};\n",
21235
21378
  "workspace-members.ts": "// workspace-members.ts \u2014 every MEMBERSHIP change of a workspace (a vxil function).\n//\n// Why a function: in active-org mode (auth.orgClaims.activeOrg) an org\n// membership IS the data boundary \u2014 it decides which workspace rows a session\n// reads \u2014 so an end-user session gets 403 server_only on every orgs write. The\n// app calls this function AS the signed-in user instead:\n//\n// await vx.fn['workspace-members']({ action: 'invite', org_id, role: 'editor' })\n//\n// and the function applies the product's rules in a few lines (the\n// \"workspaceGuard\" pattern):\n// 1. WHO is calling comes from `env.end_user` \u2014 the identity the platform\n// verified \u2014 never from the payload.\n// 2. WHAT they may do is asked with THEIR OWN token: `GET /v1/orgs/:id/check`\n// in end-user mode always checks the verified caller, and it reads the\n// live membership (revocation-grade, not the session snapshot).\n// 3. The write itself uses `server_jwts` \u2014 tokens NOT bound to the user,\n// limited to the trigger's `serverScopes`.\n//\n// Actions (payload.action):\n// create { slug, name } \u2192 a new workspace, the caller its owner, on the `free` plan\n// invite { org_id, role } \u2192 a single-use LINK invite (admin+); role admin|editor|viewer\n// accept { token } \u2192 the caller takes the invited seat\n// set_role { org_id, user_id, role } \u2192 change a member's role (admin+; owner changes need the owner)\n// remove { org_id, user_id } \u2192 remove a member (admin+), or leave (user_id = yourself)\n//\n// Bounds (per call): at most 4 reads and 2 writes against your own backend; no\n// fan-out, no loop. A seat check reads one members page and one invitations\n// page (both bounded by orgs.maxMembersPerOrg and the 200-row invitation list).\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ninterface Body {\n action?: string;\n org_id?: string;\n slug?: string;\n name?: string;\n role?: string;\n user_id?: string;\n token?: string;\n}\ntype Env = HttpFunctionEnvelope<Body>;\n\n/** Roles an invite or a role change may hand out. `owner` is never invited;\n * making someone an owner is a `set_role` by an owner. */\nconst INVITABLE = ['admin', 'editor', 'viewer'];\nconst ASSIGNABLE = ['owner', ...INVITABLE];\n/** Workspaces one user may own \u2014 the project-wide `orgs.maxOrgsPerTenant` is\n * shared by everyone, so one account cannot exhaust it. */\nconst MAX_OWNED_WORKSPACES = 5;\n/** Seats when the workspace has no plan row yet (= the `free` tier's quota). */\nconst FREE_SEATS = 3;\n\nconst fail = (status: number, code: string, message: string): Response =>\n Response.json({ error: { code, message } }, { status });\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const me = env.end_user;\n // A membership change always has a person behind it: a signed-in,\n // registered user (a guest session cannot own or join a workspace).\n if (!me || me.anon) return fail(401, 'sign_in_required', 'call this function with a signed-in user session');\n const userOrgs = env.scoped_jwts?.orgs; // acts AS the caller\n const orgs = env.server_jwts?.orgs; // acts as your backend\n const pay = env.server_jwts?.payments;\n if (!userOrgs || !orgs || !pay) return fail(500, 'misconfigured', 'orgs/payments scopes or serverScopes are missing');\n\n const call = async (token: string, method: string, path: string, body?: unknown) => {\n const res = await fetch(`${base}${path}`, {\n method,\n headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },\n ...(body ? { body: JSON.stringify(body) } : {}),\n });\n const json = (await res.json().catch(() => ({}))) as { data?: Record<string, unknown>; error?: { code?: string; message?: string } };\n return { status: res.status, ok: res.ok, data: json.data ?? {}, error: json.error };\n };\n /** Pass a refusal from the backend straight through (409 last owner, 422 cap, \u2026). */\n const relay = (r: { status: number; error?: { code?: string; message?: string } | undefined }) =>\n fail(r.status, r.error?.code ?? 'upstream_error', r.error?.message ?? `request failed (${r.status})`);\n\n /** workspaceGuard: does the CALLER hold `permission` in `orgId` right now? */\n const guard = async (orgId: string, permission: string): Promise<{ allowed: boolean; role: string | null }> => {\n const r = await call(userOrgs, 'GET',\n `/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(me.id)}&permission=${encodeURIComponent(permission)}`);\n return { allowed: r.ok && r.data.allowed === true, role: typeof r.data.role === 'string' ? r.data.role : null };\n };\n /** A member's current role in `orgId` (null = not a member). Server read. */\n const roleOf = async (orgId: string, userId: string): Promise<string | null> => {\n const r = await call(orgs, 'GET',\n `/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=org:read`);\n return r.ok && typeof r.data.role === 'string' ? r.data.role : null;\n };\n\n const p = env.payload ?? {};\n const orgId = typeof p.org_id === 'string' ? p.org_id : '';\n\n switch (p.action) {\n case 'create': {\n if (typeof p.slug !== 'string' || typeof p.name !== 'string' || !p.name.trim()) {\n return fail(422, 'validation_failed', 'need { slug, name } (slug: a-z, 0-9 and -)');\n }\n // Counts owned workspaces among the user's 100 newest memberships (one\n // read, no paging): someone in more than 100 workspaces may get past\n // the cap. A failed read refuses the create (fail closed).\n const mine = await call(orgs, 'GET', `/v1/orgs?user_id=${encodeURIComponent(me.id)}&limit=100`);\n if (!mine.ok) return relay(mine);\n const owned = ((mine.data.orgs as Array<{ role?: string }> | undefined) ?? []).filter((o) => o.role === 'owner');\n if (owned.length >= MAX_OWNED_WORKSPACES) {\n return fail(422, 'workspace_limit', `you already own ${MAX_OWNED_WORKSPACES} workspaces`);\n }\n const made = await call(orgs, 'POST', '/v1/orgs', { slug: p.slug, name: p.name.trim(), owner_user_id: me.id });\n if (!made.ok) return relay(made);\n const newOrg = String(made.data.org_id);\n // Every workspace starts on the `free` tier: a manual, open-ended grant\n // whose subject is the WORKSPACE. Idempotent on the key, so a retry is safe.\n const grant = await call(pay, 'POST', '/v1/payments/subscriptions/grant', {\n org_id: newOrg, tier: 'free', reason: 'workspace created', idempotency_key: `free:${newOrg}`,\n });\n // The app now moves the session into it: vx.auth.sessions.switchOrg(org_id)\n return Response.json({ org_id: newOrg, slug: made.data.slug, plan: grant.ok ? 'free' : null }, { status: 201 });\n }\n\n case 'invite': {\n const role = String(p.role ?? '');\n if (!orgId || !INVITABLE.includes(role)) return fail(422, 'validation_failed', `need { org_id, role } with role ${INVITABLE.join('|')}`);\n if (!(await guard(orgId, 'members:manage')).allowed) return fail(403, 'forbidden', 'only a workspace admin or owner may invite');\n // The PLAN's seats: members + still-pending invitations must stay below it.\n const [ent, members, invites] = await Promise.all([\n call(pay, 'GET', `/v1/payments/entitlements?org_id=${encodeURIComponent(orgId)}`),\n call(orgs, 'GET', `/v1/orgs/${encodeURIComponent(orgId)}/members`),\n call(orgs, 'GET', `/v1/orgs/${encodeURIComponent(orgId)}/invitations`),\n ]);\n const quota = (ent.data.quotas as Record<string, number> | undefined)?.seats;\n const seats = typeof quota === 'number' ? quota : FREE_SEATS;\n const taken = ((members.data.members as unknown[] | undefined) ?? []).length\n + ((invites.data.invitations as Array<{ pending?: boolean }> | undefined) ?? []).filter((i) => i.pending).length;\n if (taken >= seats) return fail(402, 'seats_exhausted', `the workspace plan has ${seats} seats and all are taken or invited`);\n // A LINK invite (no email): vxil sends nothing, the token is returned\n // ONCE, and your app builds the accept link and delivers it.\n const inv = await call(orgs, 'POST', `/v1/orgs/${encodeURIComponent(orgId)}/invitations`, { role });\n if (!inv.ok) return relay(inv);\n return Response.json({ invite_id: inv.data.invite_id, role, invite_token: inv.data.invite_token }, { status: 201 });\n }\n\n case 'accept': {\n if (typeof p.token !== 'string' || !p.token) return fail(422, 'validation_failed', 'need { token }');\n // The seat goes to the VERIFIED caller, never to a user id in the payload.\n const acc = await call(orgs, 'POST', '/v1/orgs/invitations/accept', { token: p.token, user_id: me.id });\n if (!acc.ok) return relay(acc);\n // The app now moves the session into it: vx.auth.sessions.switchOrg(org_id)\n return Response.json({ org_id: acc.data.org_id, role: acc.data.role });\n }\n\n case 'set_role': {\n const role = String(p.role ?? '');\n const target = String(p.user_id ?? '');\n if (!orgId || !target || !ASSIGNABLE.includes(role)) {\n return fail(422, 'validation_failed', `need { org_id, user_id, role } with role ${ASSIGNABLE.join('|')}`);\n }\n const current = await roleOf(orgId, target);\n // Only an existing member changes role \u2014 this action never adds a seat.\n if (!current) return fail(404, 'not_found', 'that user is not a member of the workspace');\n const needs = role === 'owner' || current === 'owner' ? 'org:admin' : 'members:manage';\n if (!(await guard(orgId, needs)).allowed) {\n return fail(403, 'forbidden', needs === 'org:admin' ? 'only an owner may grant or take the owner role' : 'only a workspace admin or owner may change roles');\n }\n // An org always keeps one owner: demoting the last one is a 409 from orgs.\n const set = await call(orgs, 'POST', `/v1/orgs/${encodeURIComponent(orgId)}/members`, { user_id: target, role });\n if (!set.ok) return relay(set);\n // The member's live sessions are marked stale; their next request\n // refreshes onto the new role in about 60 s.\n return Response.json({ org_id: orgId, user_id: target, role, previous: current });\n }\n\n case 'remove': {\n const target = String(p.user_id ?? '');\n if (!orgId || !target) return fail(422, 'validation_failed', 'need { org_id, user_id }');\n if (target !== me.id) {\n const current = await roleOf(orgId, target);\n if (!current) return fail(404, 'not_found', 'that user is not a member of the workspace');\n if (!(await guard(orgId, current === 'owner' ? 'org:admin' : 'members:manage')).allowed) {\n return fail(403, 'forbidden', 'only a workspace admin (or an owner, for an owner) may remove members');\n }\n }\n const del = await call(orgs, 'DELETE', `/v1/orgs/${encodeURIComponent(orgId)}/members/${encodeURIComponent(target)}`);\n if (!del.ok) return relay(del);\n return Response.json({ org_id: orgId, removed: target });\n }\n\n default:\n return fail(422, 'validation_failed', 'action must be create | invite | accept | set_role | remove');\n }\n },\n};\n"
@@ -22720,6 +22863,7 @@ function pickAuthColumns(available) {
22720
22863
  const have = new Set(available);
22721
22864
  return AUTH_USERS_SAFE_COLUMNS.filter((c) => have.has(c));
22722
22865
  }
22866
+ var UTC_ISO = (col) => `to_char(${col} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
22723
22867
  var supabaseSql = {
22724
22868
  /** does this database carry the GoTrue auth schema? */
22725
22869
  authUsersProbe: () => ({
@@ -22752,7 +22896,7 @@ var supabaseSql = {
22752
22896
  const n = Math.max(1, Math.trunc(limit));
22753
22897
  const conds = [...after !== null ? ["id > $1::uuid"] : [], ...hasDeletedAt ? ["deleted_at IS NULL"] : []];
22754
22898
  return {
22755
- text: `SELECT id::text AS id, encrypted_password, (email_confirmed_at IS NOT NULL) AS email_verified FROM auth.users${conds.length ? ` WHERE ${conds.join(" AND ")}` : ""} ORDER BY id ASC LIMIT ${n}`,
22899
+ text: `SELECT id::text AS id, encrypted_password, (email_confirmed_at IS NOT NULL) AS email_verified, ${UTC_ISO("created_at")} AS created_at, ${UTC_ISO("email_confirmed_at")} AS email_verified_at FROM auth.users${conds.length ? ` WHERE ${conds.join(" AND ")}` : ""} ORDER BY id ASC LIMIT ${n}`,
22756
22900
  params: after !== null ? [after] : []
22757
22901
  };
22758
22902
  },
@@ -22895,6 +23039,8 @@ var SupabaseAdapter = class extends GenericPgAdapter {
22895
23039
  const out = rows.map((r) => {
22896
23040
  const h = typeof r.encrypted_password === "string" && r.encrypted_password.length > 0 ? r.encrypted_password : null;
22897
23041
  const row2 = { id: String(r.id), password_hash: h, email_verified: r.email_verified === true };
23042
+ if (typeof r.created_at === "string") row2.created_at = r.created_at;
23043
+ if (typeof r.email_verified_at === "string") row2.email_verified_at = r.email_verified_at;
22898
23044
  if (subjectColumn !== null) row2.identities = links.get(String(r.id)) ?? [];
22899
23045
  return row2;
22900
23046
  });
@@ -23654,6 +23800,9 @@ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config fo
23654
23800
  compared.collections += Object.keys(cfg.cms.collections).length;
23655
23801
  });
23656
23802
  const { functions: _fnFeature, ...configFeatures } = cfg.features;
23803
+ for (const c of paymentsShadowChecks(cfg.features?.payments)) {
23804
+ console.error(`warning: ${c.detail}`);
23805
+ }
23657
23806
  const serverApiState = {};
23658
23807
  await runSection("config", async () => {
23659
23808
  if (!Object.keys(configFeatures).length) return;
@@ -24392,6 +24541,7 @@ async function runDoctor() {
24392
24541
  const pres = await api("GET", "/v1/config/payments");
24393
24542
  if (pres.status === 200) {
24394
24543
  checks.push(...paymentsWebhookSecretChecks(pres.body.data?.manifest));
24544
+ checks.push(...paymentsShadowChecks(pres.body.data?.manifest));
24395
24545
  }
24396
24546
  }
24397
24547
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "private": false,
5
5
  "description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
6
6
  "license": "MIT",
@@ -45,8 +45,8 @@
45
45
  }
46
46
  },
47
47
  "devDependencies": {
48
- "@vxil/config": "0.20.0",
49
- "@vxil/feature-configs": "0.18.0",
48
+ "@vxil/config": "0.21.0",
49
+ "@vxil/feature-configs": "0.19.0",
50
50
  "@vxil/runtime": "0.0.1",
51
51
  "@vxil/types": "0.0.1",
52
52
  "miniflare": "^4.20260730.0"