@vxil/cli 0.27.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.
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.27.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);
@@ -16805,6 +16936,8 @@ var EXTRA_KNOWN_FLAGS = [
16805
16936
  "dir",
16806
16937
  "file-links",
16807
16938
  "from-provider",
16939
+ "include",
16940
+ "owner",
16808
16941
  "include-empty",
16809
16942
  "include-password-hashes",
16810
16943
  "with-password-hashes",
@@ -17077,6 +17210,15 @@ import { dirname as dirname4, resolve as resolve5 } from "node:path";
17077
17210
  import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
17078
17211
  import { basename as basename2, resolve as resolve4 } from "node:path";
17079
17212
 
17213
+ // src/migrate/types.ts
17214
+ var OWNER_NONE = "none";
17215
+ function hasOwnerField(m) {
17216
+ return m.ownerField !== void 0 && m.ownerField !== OWNER_NONE;
17217
+ }
17218
+ function newMigrateState() {
17219
+ return { version: 1, schemaPushed: false, cursors: {}, idMap: {} };
17220
+ }
17221
+
17080
17222
  // src/migrate/inference.ts
17081
17223
  var VECTOR_DIMS = [256, 384, 512, 768, 1024, 1536, 3072];
17082
17224
  var SOURCE_ID_FIELD = "source_id";
@@ -17160,7 +17302,92 @@ function parseNumeric(pgType) {
17160
17302
  const m = /^numeric\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(pgType.trim());
17161
17303
  return m ? { precision: Number(m[1]), scale: Number(m[2]) } : void 0;
17162
17304
  }
17163
- var PAYMENTS_TABLE_RE = /(purchase|payment|invoice|credit)/i;
17305
+ var PAYMENTS_TABLE_WORDS = [
17306
+ "purchase",
17307
+ "payment",
17308
+ "invoice",
17309
+ "credit",
17310
+ // en (the original pattern)
17311
+ "pago",
17312
+ "compra",
17313
+ "factura",
17314
+ "suscripci",
17315
+ // es
17316
+ "pagamento",
17317
+ "fatura",
17318
+ "assinatura",
17319
+ // pt
17320
+ "paiement",
17321
+ "achat",
17322
+ "facture",
17323
+ "abonnement",
17324
+ "cr\xE9dit",
17325
+ // fr (abonnement: de too)
17326
+ "zahlung",
17327
+ "kauf",
17328
+ "rechnung",
17329
+ "guthaben",
17330
+ // de
17331
+ "odeme",
17332
+ "\xF6deme",
17333
+ "abonelik",
17334
+ "kredi",
17335
+ // tr
17336
+ "pembayaran",
17337
+ "pembelian",
17338
+ "tagihan",
17339
+ "langganan",
17340
+ // id
17341
+ "oplat",
17342
+ "platezh",
17343
+ "\u043F\u043B\u0430\u0442\u0435\u0436",
17344
+ "\u043F\u043B\u0430\u0442\u0451\u0436",
17345
+ "\u043E\u043F\u043B\u0430\u0442",
17346
+ "\u043F\u043E\u043A\u0443\u043F\u043A",
17347
+ "\u0441\u0447\u0435\u0442",
17348
+ "\u0441\u0447\u0451\u0442",
17349
+ "\u043F\u043E\u0434\u043F\u0438\u0441\u043A",
17350
+ "\u043A\u0440\u0435\u0434\u0438\u0442",
17351
+ // ru
17352
+ "\u062F\u0641\u0639",
17353
+ "\u0645\u062F\u0641\u0648\u0639",
17354
+ "\u0641\u0627\u062A\u0648\u0631",
17355
+ "\u0627\u0634\u062A\u0631\u0627\u0643",
17356
+ "\u0634\u0631\u0627\u0621",
17357
+ "\u0631\u0635\u064A\u062F",
17358
+ // ar
17359
+ "\u0627\u062F\u0627\u0626\u06CC\u06AF\u06CC",
17360
+ "\u062E\u0631\u06CC\u062F\u0627\u0631\u06CC",
17361
+ "\u0627\u0646\u0648\u0627\u0626\u0633",
17362
+ "\u0631\u06A9\u0646\u06CC\u062A",
17363
+ // ur
17364
+ "\u092D\u0941\u0917\u0924\u093E\u0928",
17365
+ "\u0916\u0930\u0940\u0926",
17366
+ "\u091A\u093E\u0932\u093E\u0928",
17367
+ "\u0938\u0926\u0938\u094D\u092F\u0924\u093E",
17368
+ // hi
17369
+ "\u09AA\u09C7\u09AE\u09C7\u09A8\u09CD\u099F",
17370
+ "\u0985\u09B0\u09CD\u09A5\u09AA\u09CD\u09B0\u09A6\u09BE\u09A8",
17371
+ "\u0995\u09CD\u09B0\u09AF\u09BC",
17372
+ "\u099A\u09BE\u09B2\u09BE\u09A8",
17373
+ // bn
17374
+ "\u652F\u4ED8",
17375
+ "\u4ED8\u6B3E",
17376
+ "\u8D2D\u4E70",
17377
+ "\u8CFC\u8CB7",
17378
+ "\u53D1\u7968",
17379
+ "\u767C\u7968",
17380
+ "\u8BA2\u9605",
17381
+ "\u8A02\u95B1",
17382
+ "\u5145\u503C",
17383
+ // zh
17384
+ "\uACB0\uC81C",
17385
+ "\uAD6C\uB9E4",
17386
+ "\uCCAD\uAD6C",
17387
+ "\uAD6C\uB3C5"
17388
+ // ko
17389
+ ];
17390
+ var PAYMENTS_TABLE_RE = new RegExp(PAYMENTS_TABLE_WORDS.join("|"), "iu");
17164
17391
  var WEBHOOK_LOG_RE = /webhook/i;
17165
17392
  var SUBSCRIPTION_TABLE_RE = /(subscription|entitlement)/i;
17166
17393
  var PUSH_TOKEN_RE = /(push_token|device_token|fcm_token|apns|expo_token)/i;
@@ -17173,6 +17400,17 @@ function isAuthParent(parentTable, authTable) {
17173
17400
  return !parentTable.includes(".") || !authTable.includes(".");
17174
17401
  }
17175
17402
  var OWNER_NAMES = ["user_id", "owner_id", "author_id"];
17403
+ var OWNER_LIKE_RE = new RegExp(
17404
+ "^(?:uid|(?:owner|author|creator|createdby|postedby|uploader|user|member|usuario|propietario|dueno|due\xF1o|autor|auteur|utilisateur|proprietaire|propri\xE9taire|benutzer|nutzer|besitzer|kullanici|kullan\u0131c\u0131|sahip|pengguna|pemilik|polzovatel|vladelets|usu\xE1rio|dono)(?:id|uuid|ref)?)$",
17405
+ "iu"
17406
+ );
17407
+ function isOwnerLikeName(column) {
17408
+ return OWNER_LIKE_RE.test(column.replace(/[_-]/g, ""));
17409
+ }
17410
+ function ownerLikeType(c) {
17411
+ if (c.pgType) return !["boolean", "bool", "json", "jsonb"].includes(pgBase(c.pgType)) && !/^(?:timestamp|date|time)/.test(pgBase(c.pgType));
17412
+ return c.inferredType === void 0 || c.inferredType === "string" || c.inferredType === "number";
17413
+ }
17176
17414
  function ruleOwnerColumn(definition) {
17177
17415
  const m = /auth\.uid\(\)(?:\s+AS\s+\w+\s*\))?\s*=\s*(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?/i.exec(definition) ?? /(?:[\w"]+\.)?"?([A-Za-z_]\w*)"?\s*=\s*(?:\(\s*SELECT\s+)?auth\.uid\(\)/i.exec(definition);
17178
17416
  return m?.[1];
@@ -17188,21 +17426,32 @@ function pickOwner(candidates) {
17188
17426
  for (const name of OWNER_NAMES) if (candidates.includes(name)) return { column: name, ambiguous: false };
17189
17427
  return { column: candidates[0], ambiguous: candidates.length > 1 };
17190
17428
  }
17191
- function detectOwner(table, source, authTable, opts) {
17429
+ function detectOwner(table, source, authTable, opts, userKeyed = /* @__PURE__ */ new Set()) {
17192
17430
  const cols = new Set(table.columns.map((c) => c.name));
17193
17431
  const override = opts.owners?.[table.name];
17194
- if (override && cols.has(override)) return { column: override, via: "override" };
17432
+ if (override === OWNER_NONE) return void 0;
17433
+ if (override && cols.has(override)) return { column: override, via: "--owner" };
17195
17434
  for (const p of source.rlsPolicies ?? []) {
17196
17435
  if (lastSeg(p.table) !== table.name) continue;
17197
17436
  const col = ruleOwnerColumn(p.definition);
17198
17437
  if (col && cols.has(col)) return { column: col, via: `row-level access rule${p.name ? ` '${p.name}'` : ""}` };
17199
17438
  }
17200
- const fkCols = table.columns.map((c) => c.name).filter((name) => source.foreignKeys.some((fk) => fk.childTable === table.name && fk.childColumns.length === 1 && fk.childColumns[0] === name && isAuthParent(fk.parentTable, authTable)));
17439
+ const userFkParent = (name) => {
17440
+ for (const fk of source.foreignKeys) {
17441
+ if (fk.childTable !== table.name || fk.childColumns.length !== 1 || fk.childColumns[0] !== name) continue;
17442
+ if (isAuthParent(fk.parentTable, authTable)) return authTable;
17443
+ const parent = lastSeg(fk.parentTable);
17444
+ if (parent !== table.name && userKeyed.has(parent)) return parent;
17445
+ }
17446
+ return void 0;
17447
+ };
17448
+ const fkCols = table.columns.map((c) => c.name).filter((name) => userFkParent(name) !== void 0);
17201
17449
  if (fkCols.length > 0) {
17202
17450
  const pick = pickOwner(fkCols);
17451
+ const parent = userFkParent(pick.column);
17203
17452
  return {
17204
17453
  column: pick.column,
17205
- via: `FK \u2192 ${authTable}`,
17454
+ via: parent === authTable ? `FK \u2192 ${authTable}` : `FK \u2192 ${parent} (its id is the user id)`,
17206
17455
  ...pick.ambiguous || fkCols.length > 1 ? { ambiguousAmong: fkCols } : {}
17207
17456
  };
17208
17457
  }
@@ -17359,9 +17608,24 @@ function inferMapping(source, opts = {}) {
17359
17608
  const vectorTargets = [];
17360
17609
  const skipSet = new Set(opts.skipTables ?? []);
17361
17610
  const paymentsSet = new Set(opts.paymentsTables ?? []);
17362
- for (const name of [...skipSet, ...paymentsSet]) {
17363
- if (!source.tables.some((t) => t.name === name || `${t.schema ?? "public"}.${t.name}` === name)) {
17364
- warnings.push(`'${name}' (${skipSet.has(name) ? "--skip" : "migrate/payments.json"}) names no source table \u2014 it selects nothing`);
17611
+ const includeSet = new Set(opts.includeTables ?? []);
17612
+ const ownerOverrides = Object.keys(opts.owners ?? {});
17613
+ const findTable = (name) => source.tables.find((t) => t.name === name || `${t.schema ?? "public"}.${t.name}` === name);
17614
+ for (const [name, via] of [
17615
+ ...[...skipSet].map((n) => [n, "--skip"]),
17616
+ ...[...paymentsSet].map((n) => [n, "migrate/payments.json"]),
17617
+ ...[...includeSet].map((n) => [n, "--include"]),
17618
+ ...ownerOverrides.map((n) => [n, "--owner"])
17619
+ ]) {
17620
+ if (!findTable(name)) warnings.push(`'${name}' (${via}) names no source table \u2014 it selects nothing`);
17621
+ }
17622
+ for (const name of includeSet) {
17623
+ if (skipSet.has(name)) warnings.push(`'${name}' is named by both --skip and --include \u2014 --skip wins`);
17624
+ }
17625
+ for (const [name, col] of Object.entries(opts.owners ?? {})) {
17626
+ const t = findTable(name);
17627
+ if (t && col !== OWNER_NONE && !t.columns.some((c) => c.name === col)) {
17628
+ warnings.push(`--owner ${name}=${col}: ${name} has no column '${col}' \u2014 ignored (columns: ${t.columns.map((c) => c.name).join(", ")})`);
17365
17629
  }
17366
17630
  }
17367
17631
  const fksByChild = /* @__PURE__ */ new Map();
@@ -17388,10 +17652,17 @@ function inferMapping(source, opts = {}) {
17388
17652
  for (const t of source.tables) {
17389
17653
  const reasons = [];
17390
17654
  let target;
17655
+ let confirm;
17391
17656
  const qualified = `${t.schema ?? "public"}.${t.name}`;
17657
+ const included = includeSet.has(t.name) || includeSet.has(qualified);
17658
+ const nameSkip = SUBSCRIPTION_TABLE_RE.test(t.name) ? `its name matches the subscription/entitlement pattern (provider-derived state is never seeded)` : WEBHOOK_LOG_RE.test(t.name) ? `its name matches the webhook pattern (a provider webhook/event log \u2014 vxil keeps its own delivery log)` : void 0;
17392
17659
  if (skipSet.has(t.name) || skipSet.has(qualified)) {
17393
17660
  target = "skip";
17394
17661
  reasons.push("skipped by option (--skip)");
17662
+ } else if (included && (t.extensionOwned || t.schema === "auth" || t.schema === "storage" || qualified === authTable || t.name === authTable)) {
17663
+ target = "skip";
17664
+ reasons.push(`--include ignored: ${qualified} is an auth, storage or extension table, never a cms collection`);
17665
+ warnings.push(`--include ${t.name}: an auth, storage or extension table is never loaded as a cms collection \u2014 ignored`);
17395
17666
  } else if (t.extensionOwned) {
17396
17667
  target = "skip";
17397
17668
  reasons.push(`T8: ${qualified} belongs to an installed extension \u2014 the extension recreates it; not app data`);
@@ -17404,22 +17675,26 @@ function inferMapping(source, opts = {}) {
17404
17675
  } else if (paymentsSet.has(t.name) || paymentsSet.has(qualified)) {
17405
17676
  target = "payments-credits";
17406
17677
  reasons.push(`T8: ${t.name} is listed in migrate/payments.json \u2192 payments credits via grantOnce (idempotent grant_key); never cms`);
17407
- } else if (SUBSCRIPTION_TABLE_RE.test(t.name)) {
17408
- target = "skip";
17409
- reasons.push(`subscription/entitlement table ${t.name}: provider-derived \u2014 never seeded`);
17410
- residuals.push(`subscription/entitlement state (${t.name}): NOT seeded \u2014 rebuild from live provider webhooks after cutover`);
17411
- } else if (WEBHOOK_LOG_RE.test(t.name)) {
17678
+ } else if (nameSkip !== void 0 && !included) {
17412
17679
  target = "skip";
17413
- reasons.push(`T8: ${t.name} looks like a provider webhook/event log \u2014 vxil keeps its own delivery log; not loaded (to seed credits from it, list it in migrate/payments.json and re-run plan)`);
17414
- } else if (t.rowCountEstimate === 0 && !opts.includeEmpty && policiesOn(t) + fkEdgesOn(t) === 0) {
17680
+ confirm = `${t.name}: ${nameSkip} \u2014 not loaded. If it is app data, re-run plan with \`--include ${t.name}\`; \`--skip ${t.name}\` confirms the skip`;
17681
+ warnings.push(`SKIP (confirm): ${confirm}`);
17682
+ if (SUBSCRIPTION_TABLE_RE.test(t.name)) {
17683
+ reasons.push(`R1: skip (confirm): ${confirm}`);
17684
+ residuals.push(`subscription/entitlement state (${t.name}): NOT seeded \u2014 rebuild from live provider webhooks after cutover`);
17685
+ } else {
17686
+ reasons.push(`T8: skip (confirm): ${confirm} (to seed credits from it instead, list it in migrate/payments.json and re-run plan)`);
17687
+ }
17688
+ } else if (t.rowCountEstimate === 0 && !opts.includeEmpty && !included && policiesOn(t) + fkEdgesOn(t) === 0) {
17415
17689
  target = "skip";
17416
17690
  reasons.push("R1: 0 rows \u2014 likely dropped/legacy; skipped (confirm; --include-empty keeps it)");
17417
17691
  } else {
17418
17692
  target = "cms";
17419
17693
  reasons.push(`R1: ${qualified} \u2192 cms collection '${toCollectionName(t.name)}'`);
17694
+ if (included && nameSkip !== void 0) reasons.push(`kept by --include (${nameSkip})`);
17420
17695
  if (t.rowCountEstimate === 0) {
17421
- if (opts.includeEmpty) {
17422
- reasons.push("R1: 0 rows by the catalog estimate \u2014 kept (--include-empty)");
17696
+ if (opts.includeEmpty || included) {
17697
+ reasons.push(`R1: 0 rows by the catalog estimate \u2014 kept (${included ? "--include" : "--include-empty"})`);
17423
17698
  } else {
17424
17699
  reasons.push("T1: 0 rows by the catalog estimate, but it carries row-level rules/FKs \u2014 kept");
17425
17700
  warnings.push(
@@ -17433,20 +17708,32 @@ function inferMapping(source, opts = {}) {
17433
17708
  );
17434
17709
  }
17435
17710
  }
17436
- classified.push({ table: t, target, reasons });
17711
+ classified.push({ table: t, target, reasons, ...confirm !== void 0 ? { confirm } : {} });
17437
17712
  }
17438
17713
  const cmsByName = /* @__PURE__ */ new Map();
17439
17714
  for (const c of classified) {
17440
17715
  if (c.target === "cms") cmsByName.set(c.table.name, { table: c.table, collection: toCollectionName(c.table.name) });
17441
17716
  }
17442
17717
  const owners = /* @__PURE__ */ new Map();
17443
- const profileCandidates = [];
17718
+ let profileCandidates = [];
17719
+ for (let pass = 0; pass <= cmsByName.size; pass++) {
17720
+ const userKeyed = new Set(profileCandidates);
17721
+ owners.clear();
17722
+ const next = [];
17723
+ for (const { table } of cmsByName.values()) {
17724
+ const o = detectOwner(table, source, authTable, opts, userKeyed);
17725
+ if (!o) continue;
17726
+ owners.set(table.name, o);
17727
+ const pkCols = table.columns.filter((c) => c.isPk);
17728
+ if (pkCols.length === 1 && pkCols[0].name === o.column) next.push(table.name);
17729
+ }
17730
+ const stable = next.length === profileCandidates.length && next.every((n) => userKeyed.has(n));
17731
+ profileCandidates = next;
17732
+ if (stable) break;
17733
+ }
17444
17734
  for (const { table } of cmsByName.values()) {
17445
- const o = detectOwner(table, source, authTable, opts);
17735
+ const o = owners.get(table.name);
17446
17736
  if (!o) continue;
17447
- owners.set(table.name, o);
17448
- const pkCols = table.columns.filter((c) => c.isPk);
17449
- if (pkCols.length === 1 && pkCols[0].name === o.column) profileCandidates.push(table.name);
17450
17737
  if (o.ambiguousAmong) {
17451
17738
  const preferred = OWNER_NAMES.includes(o.column);
17452
17739
  const line = `T6: ${table.name} has ${o.ambiguousAmong.length} user-ref columns (${o.ambiguousAmong.join(", ")}) \u2014 owner '${o.column}'${preferred ? " (the preferred owner name)" : " is a GUESS"}; confirm ownerField in plan.json`;
@@ -17475,9 +17762,9 @@ function inferMapping(source, opts = {}) {
17475
17762
  }
17476
17763
  }
17477
17764
  }
17478
- for (const { table: t, target, reasons } of classified) {
17765
+ for (const { table: t, target, reasons, confirm } of classified) {
17479
17766
  if (target !== "cms") {
17480
- const owner2 = target === "payments-credits" ? detectOwner(t, source, authTable, opts) : void 0;
17767
+ const owner2 = target === "payments-credits" ? detectOwner(t, source, authTable, opts, new Set(profileCandidates)) : void 0;
17481
17768
  const m = {
17482
17769
  table: t.name,
17483
17770
  target,
@@ -17485,7 +17772,8 @@ function inferMapping(source, opts = {}) {
17485
17772
  // end_user_id / the grantOnce grant_key) — no id capture needed.
17486
17773
  idRemap: "value-keyed",
17487
17774
  fields: target === "users" ? authUserFields(t.columns.map((c) => c.name)) : [],
17488
- reasons
17775
+ reasons,
17776
+ ...confirm !== void 0 ? { confirm } : {}
17489
17777
  };
17490
17778
  const ref = target === "users" ? "id" : owner2?.column;
17491
17779
  if (ref !== void 0) m.userRefColumn = ref;
@@ -17639,11 +17927,27 @@ function inferMapping(source, opts = {}) {
17639
17927
  }
17640
17928
  fields.push(field);
17641
17929
  }
17930
+ if (!owner) {
17931
+ const candidates = t.columns.filter((c) => ownerLikeType(c) && (c.matchesUserIds === true || !fks.has(c.name) && isOwnerLikeName(c.name)));
17932
+ if (candidates.length > 0) {
17933
+ mapping.ownerCandidates = candidates.map((c) => c.name);
17934
+ const why = candidates.map((c) => `${c.name} (${isOwnerLikeName(c.name) ? "its name" : "its values are user ids"})`).join(", ");
17935
+ if (opts.owners?.[t.name] !== OWNER_NONE) {
17936
+ warnings.push(
17937
+ `OWNER (decide): ${t.name} has no owner rule or FK to the users, but ${why} may hold the row owner's user id \u2014 without an owner every end user can read every row. Re-run plan with \`--owner ${t.name}=${candidates[0].name}\` to scope each row to that user, or \`--owner ${t.name}=none\` to import it unscoped on purpose; \`schema --apply\` and \`data --execute\` refuse until then`
17938
+ );
17939
+ }
17940
+ }
17941
+ if (opts.owners?.[t.name] === OWNER_NONE) {
17942
+ mapping.ownerField = OWNER_NONE;
17943
+ mapping.reasons.push(`R6: no owner scoping \u2014 --owner ${t.name}=none (any caller holding cms scopes reads every row)`);
17944
+ }
17945
+ }
17642
17946
  const tokenCols = t.columns.map((c) => c.name).filter((c) => PUSH_TOKEN_RE.test(c));
17643
17947
  if (PUSH_TOKEN_RE.test(t.name) || tokenCols.length > 0) {
17644
17948
  mapping.reasons.push(`T4: push device tokens${tokenCols.length ? ` (${tokenCols.join(", ")})` : ""} \u2014 copied as app data`);
17645
17949
  residuals.push(
17646
- `push device tokens (${t.name}${tokenCols.length ? `: ${tokenCols.join(", ")}` : ""}): copied as app data into cms '${collection}'${mapping.ownerField ? ` (owner-scoped on ${mapping.ownerField})` : ""} \u2014 vxil has no push provider; send through a function over egress (the push-notifications blueprint), or \`--skip ${t.name}\` and let clients re-enroll after cutover`
17950
+ `push device tokens (${t.name}${tokenCols.length ? `: ${tokenCols.join(", ")}` : ""}): copied as app data into cms '${collection}'${hasOwnerField(mapping) ? ` (owner-scoped on ${mapping.ownerField})` : ""} \u2014 vxil has no push provider; send through a function over egress (the push-notifications blueprint), or \`--skip ${t.name}\` and let clients re-enroll after cutover`
17647
17951
  );
17648
17952
  }
17649
17953
  const change = detectChangeColumn(t, source);
@@ -17718,9 +18022,9 @@ function inferMapping(source, opts = {}) {
17718
18022
  carried = `table not loaded as cms (${m?.target ?? "not in the plan"}) \u2014 nothing to carry`;
17719
18023
  } else if (isOpenRule(p.definition)) {
17720
18024
  carried = `open rule \u2014 nothing to carry (any caller holding cms scopes reads '${m.collection}')`;
17721
- } else if (m.ownerField && m.userRefColumn !== void 0 && ruleOwnerColumn(p.definition) === m.userRefColumn) {
18025
+ } else if (hasOwnerField(m) && m.userRefColumn !== void 0 && ruleOwnerColumn(p.definition) === m.userRefColumn) {
17722
18026
  carried = isSoleOwnerRule(p.definition, m.userRefColumn) ? `carried by ownerField '${m.ownerField}' of '${m.collection}'` : `partly carried by ownerField '${m.ownerField}' of '${m.collection}': the rest of the rule (an OR / AND / EXISTS branch or another column) is dropped \u2014 re-express it (a public read model, a gateway function on a server key, or a validate hook)`;
17723
- } else if (m.ownerField) {
18027
+ } else if (hasOwnerField(m)) {
17724
18028
  carried = `NOT expressed by ownerField '${m.ownerField}' of '${m.collection}' \u2014 re-express it (a gateway function on a server key, a validate hook, or a public read model)`;
17725
18029
  } else {
17726
18030
  carried = `'${m.collection}' has no ownerField \u2014 re-express it (a gateway function on a server key; strictEndUserScope makes an unowned collection server-only)`;
@@ -17792,11 +18096,6 @@ function topoOrder(cmsNames, fks, profileTable, warnings) {
17792
18096
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
17793
18097
  import { dirname as dirname3 } from "node:path";
17794
18098
 
17795
- // src/migrate/types.ts
17796
- function newMigrateState() {
17797
- return { version: 1, schemaPushed: false, cursors: {}, idMap: {} };
17798
- }
17799
-
17800
18099
  // src/migrate/loaders/delta.ts
17801
18100
  import { createHash as createHash2 } from "node:crypto";
17802
18101
  function canonical(v) {
@@ -18332,6 +18631,8 @@ function credentialEntry(row2, withHashes) {
18332
18631
  id: row2.id,
18333
18632
  ...withHashes && row2.password_hash ? { password_hash: row2.password_hash } : {},
18334
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 } : {},
18335
18636
  ...identities.length > 0 ? { identities } : {}
18336
18637
  };
18337
18638
  }
@@ -18462,7 +18763,7 @@ function mappingToCollection(m) {
18462
18763
  }
18463
18764
  return {
18464
18765
  fields,
18465
- ...m.ownerField !== void 0 ? { ownerField: m.ownerField } : {}
18766
+ ...hasOwnerField(m) ? { ownerField: m.ownerField } : {}
18466
18767
  };
18467
18768
  }
18468
18769
  function planToCollections(plan) {
@@ -18572,6 +18873,7 @@ function apiErr(prefix, res) {
18572
18873
  }
18573
18874
 
18574
18875
  // src/migrate/loaders/files.ts
18876
+ import { createHash as createHash3 } from "node:crypto";
18575
18877
  async function materialize(s) {
18576
18878
  if (s instanceof Uint8Array) return s;
18577
18879
  const chunks = [];
@@ -18797,7 +19099,12 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18797
19099
  ...orgId ? { org_id: orgId } : {},
18798
19100
  filename: obj.path.split("/").pop() ?? obj.path,
18799
19101
  content_type: contentType,
18800
- 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")
18801
19108
  });
18802
19109
  if (created.status !== 201 && created.status !== 200) {
18803
19110
  const code = created.body.error?.code;
@@ -18806,12 +19113,13 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18806
19113
  }
18807
19114
  const objectId = created.body.data?.object_id;
18808
19115
  const uploadUrl = created.body.data?.upload_url;
19116
+ const uploadHeaders = created.body.data?.upload_headers;
18809
19117
  if (typeof objectId !== "string" || typeof uploadUrl !== "string") {
18810
19118
  return `files: upload-url for '${obj.path}' returned no object_id/upload_url`;
18811
19119
  }
18812
19120
  const putRes = await put(uploadUrl, {
18813
19121
  method: "PUT",
18814
- headers: { "content-type": contentType },
19122
+ headers: isStringRecord(uploadHeaders) ? uploadHeaders : { "content-type": contentType },
18815
19123
  body: bytes
18816
19124
  });
18817
19125
  if (!putRes.ok) {
@@ -18824,6 +19132,9 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18824
19132
  map2[obj.path] = objectId;
18825
19133
  return void 0;
18826
19134
  }
19135
+ function isStringRecord(v) {
19136
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
19137
+ }
18827
19138
 
18828
19139
  // src/migrate/loaders/payments.ts
18829
19140
  async function loadPaymentsCredits({ deps, adapter, mapping, state, save, spec, pkColumn, delta }) {
@@ -19772,12 +20083,21 @@ function emitResidualsMd(plan, sourceLabel) {
19772
20083
  }
19773
20084
  return lines.join("\n") + "\n";
19774
20085
  }
20086
+ function undecidedOwners(plan, only) {
20087
+ const steps = only?.length ? new Set(only) : null;
20088
+ return plan.tables.filter((t) => t.target === "cms" && (t.ownerCandidates?.length ?? 0) > 0 && t.ownerField === void 0 && (!steps || steps.has("cms") || steps.has("vectors") || steps.has(t.table)));
20089
+ }
20090
+ function undecidedOwnersText(tables) {
20091
+ const list = tables.map((t) => `${t.table} (${t.ownerCandidates.join(", ")})`).join(", ");
20092
+ const first = tables[0];
20093
+ return `${tables.length} table(s) have an id-like column that may hold the row owner, and no owner decision: ${list}. Re-run \`vxil migrate plan \u2026 --owner ${first.table}=${first.ownerCandidates[0]}\` to scope each row to that user, or \`--owner ${first.table}=none\` to import it unscoped on purpose (or set "ownerField" in ${MIGRATE_DIR}/plan.json)`;
20094
+ }
19775
20095
  function formatPlanSummary(plan) {
19776
20096
  const rows = plan.tables.map((t) => [
19777
20097
  t.table,
19778
- t.target,
20098
+ t.confirm !== void 0 ? "skip (confirm)" : t.target,
19779
20099
  t.target === "cms" ? t.collection ?? "" : "\u2014",
19780
- t.ownerField ?? t.userRefColumn ?? "\u2014",
20100
+ t.ownerField ?? t.userRefColumn ?? (t.ownerCandidates?.length ? `? ${t.ownerCandidates.join("|")} (decide)` : "\u2014"),
19781
20101
  t.target === "cms" || t.target === "users" ? String(t.fields.length) : "\u2014"
19782
20102
  ]);
19783
20103
  const head = ["TABLE", "TARGET", "COLLECTION", "OWNER/USER-REF", "FIELDS"];
@@ -19870,6 +20190,9 @@ function loaderDeps({ api, cwd, log }, dryRun) {
19870
20190
  async function runMigrateSchema(io, opts = {}) {
19871
20191
  const plan = readPlanFile(io.cwd);
19872
20192
  const apply = opts.apply === true;
20193
+ const undecided = undecidedOwners(plan);
20194
+ if (undecided.length && apply) throw new Error(`migrate schema --apply refused: ${undecidedOwnersText(undecided)} (nothing was written)`);
20195
+ if (undecided.length) io.log(` \u26A0 ${undecidedOwnersText(undecided)} \u2014 \`--apply\` refuses until then`);
19873
20196
  const missing = apply ? await missingPlanFeatures(io.api, planFeatureNeeds(plan, { step: "schema" })) : [];
19874
20197
  if (missing.length) throw new Error(`migrate schema --apply refused: ${missingFeaturesText(missing)} (nothing was written)`);
19875
20198
  if (apply) ensureStateIgnored(io.cwd, io.log);
@@ -19913,6 +20236,9 @@ async function runMigrateData(io, opts = {}) {
19913
20236
  io.log(` \u26A0 migrate/payments.json names '${table}' but plan.json maps it to ${m.target} \u2014 re-run \`vxil migrate plan\` to plan it as payments credits`);
19914
20237
  }
19915
20238
  }
20239
+ const undecided = undecidedOwners(plan, opts.only);
20240
+ if (undecided.length && execute) throw new Error(`migrate data --execute refused: ${undecidedOwnersText(undecided)} (nothing was written)`);
20241
+ if (undecided.length) io.log(` \u26A0 ${undecidedOwnersText(undecided)} \u2014 \`--execute\` refuses until then`);
19916
20242
  const missing = execute ? await missingPlanFeatures(io.api, planFeatureNeeds(plan, {
19917
20243
  step: "data",
19918
20244
  ...opts.only ? { only: opts.only } : {},
@@ -20974,7 +21300,7 @@ export default defineConfig({
20974
21300
  "hasFunctions": true,
20975
21301
  "byoKeys": [],
20976
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",
20977
- "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",
20978
21304
  "functions": {
20979
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"
20980
21306
  }
@@ -21046,7 +21372,7 @@ export default defineConfig({
21046
21372
  "hasFunctions": true,
21047
21373
  "byoKeys": [],
21048
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",
21049
- "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",
21050
21376
  "functions": {
21051
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",
21052
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"
@@ -21575,6 +21901,19 @@ export default defineConfig({
21575
21901
  // src/migrate/adapters/csv-json.ts
21576
21902
  import { readFileSync as readFileSync7, readdirSync, existsSync as existsSync7, statSync } from "node:fs";
21577
21903
  import { basename as basename3, extname, join as join3, resolve as resolve6 } from "node:path";
21904
+
21905
+ // src/migrate/adapters/identity.ts
21906
+ var IDENTITY_COLUMNS = ["id", "email", "display_name", "avatar_url", "raw_user_meta_data"];
21907
+ function splitIdentityColumns(columns) {
21908
+ const keep = columns.filter((c) => IDENTITY_COLUMNS.includes(c));
21909
+ return { keep, dropped: columns.filter((c) => !keep.includes(c)) };
21910
+ }
21911
+ function identityDroppedWarning(adapter, table, dropped) {
21912
+ if (dropped.length === 0) return void 0;
21913
+ return `${adapter}: the identity read of '${table}' carries only ${IDENTITY_COLUMNS.join(", ")} \u2014 not read (not on the identity allowlist): ${dropped.join(", ")}`;
21914
+ }
21915
+
21916
+ // src/migrate/adapters/csv-json.ts
21578
21917
  function parseCsv(text) {
21579
21918
  const src = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
21580
21919
  const rows = [];
@@ -21764,6 +22103,9 @@ var CsvJsonAdapter = class {
21764
22103
  columns: cols,
21765
22104
  hasPasswordHash: cols.some((c) => PASSWORDISH_RE.test(c))
21766
22105
  };
22106
+ const dropped = identityDroppedWarning("csv-json", at.name, splitIdentityColumns(cols).dropped);
22107
+ if (dropped) warnings.push(dropped);
22108
+ this.markUserIdColumns(at, tables);
21767
22109
  } else {
21768
22110
  warnings.push(`csv-json: _schema.json authTable '${overrides.authTable}' has no matching file`);
21769
22111
  }
@@ -21871,22 +22213,39 @@ var CsvJsonAdapter = class {
21871
22213
  }) : t.rows;
21872
22214
  yield* this.paged(rows, cursor);
21873
22215
  }
21874
- /** identity rows from the _schema.json authTable, password-ish columns
21875
- * STRIPPED (hash values never leave the export dir — the PII rule). */
22216
+ /** identity rows from the _schema.json authTable — ONLY the identity
22217
+ * allowlist (IDENTITY_COLUMNS): every other column, a hash in any
22218
+ * language included, never leaves the export dir (the PII rule). */
21876
22219
  async *readAuthUsers(cursor) {
21877
22220
  const { schema, tables } = this.load();
21878
22221
  if (!schema.authUsers?.present || !schema.authUsers.table) {
21879
22222
  throw new Error("csv-json: no authTable declared in _schema.json \u2014 identity step unavailable");
21880
22223
  }
21881
22224
  const t = tables.get(schema.authUsers.table);
21882
- const strip = t.columns.map((c) => c.name).filter((c) => PASSWORDISH_RE.test(c));
21883
- const rows = strip.length === 0 ? t.rows : t.rows.map((r) => {
21884
- const out = { ...r };
21885
- for (const c of strip) delete out[c];
22225
+ const { keep } = splitIdentityColumns(t.columns.map((c) => c.name));
22226
+ const rows = t.rows.map((r) => {
22227
+ const out = {};
22228
+ for (const c of keep) if (c in r) out[c] = r[c];
21886
22229
  return out;
21887
22230
  });
21888
22231
  yield* this.paged(rows, cursor);
21889
22232
  }
22233
+ /** R6 owner candidates: a string column of another table whose sampled
22234
+ * non-null values are ALL ids of the auth table (strings only — small
22235
+ * integer ids would match any counter column). */
22236
+ markUserIdColumns(auth, tables) {
22237
+ if (!auth.columns.some((c) => c.name === "id")) return;
22238
+ const ids = new Set(auth.rows.map((r) => r.id).filter((v) => typeof v === "string" && v !== ""));
22239
+ if (ids.size === 0) return;
22240
+ for (const t of tables.values()) {
22241
+ if (t === auth) continue;
22242
+ for (const col of t.columns) {
22243
+ if (col.inferredType !== "string") continue;
22244
+ const sample = t.rows.slice(0, this.sampleSize).map((r) => r[col.name]).filter((v) => v !== null && v !== void 0 && v !== "");
22245
+ if (sample.length > 0 && sample.every((v) => typeof v === "string" && ids.has(v))) col.matchesUserIds = true;
22246
+ }
22247
+ }
22248
+ }
21890
22249
  /** storage objects from the `_objects/<bucket>/**` manifest (R14). Bytes are
21891
22250
  * read LAZILY per object via stream() — a dry-run never opens them. */
21892
22251
  async *readObjects(bucket) {
@@ -22389,14 +22748,25 @@ var GenericPgAdapter = class {
22389
22748
  return;
22390
22749
  }
22391
22750
  const columns = t.columns.map((c) => c.name);
22751
+ const table = this.displayName(t.schema ?? this.schemas[0], t.name);
22392
22752
  const auth = {
22393
22753
  present: true,
22394
- table: this.displayName(t.schema ?? this.schemas[0], t.name),
22754
+ table,
22395
22755
  columns,
22396
22756
  hasPasswordHash: columns.some((c) => PASSWORDISH_RE2.test(c))
22397
22757
  };
22398
22758
  if (t.rowCountEstimate !== void 0) auth.count = t.rowCountEstimate;
22399
22759
  schema.authUsers = auth;
22760
+ const dropped = identityDroppedWarning(this.providerName, table, this.identityColumns(t).dropped);
22761
+ if (dropped) schema.warnings.push(dropped);
22762
+ }
22763
+ /** the identity allowlist (identity.ts) plus the single pk the keyset
22764
+ * page needs — never a pattern-filtered "everything else". */
22765
+ identityColumns(t) {
22766
+ const pk = t.columns.filter((c) => c.isPk).map((c) => c.name);
22767
+ const split = splitIdentityColumns(t.columns.map((c) => c.name));
22768
+ const keep = [...split.keep, ...pk.length === 1 && !split.keep.includes(pk[0]) ? pk : []];
22769
+ return { keep, dropped: split.dropped.filter((c) => !keep.includes(c)) };
22400
22770
  }
22401
22771
  findTableIn(schema, table) {
22402
22772
  return schema.tables.find((t) => t.name === table || `${t.schema ?? ""}.${t.name}` === table);
@@ -22450,17 +22820,17 @@ var GenericPgAdapter = class {
22450
22820
  else offset += rows.length;
22451
22821
  }
22452
22822
  }
22453
- /** generic identity read: the `--auth-table` rows MINUS password/secret-ish
22454
- * columns (hash values are never read out — the PII rule; the
22455
- * supabase subclass overrides with the auth.users allowlist). */
22823
+ /** generic identity read: the `--auth-table` rows, identity ALLOWLIST
22824
+ * columns only (identity.ts — a hash column in any language is never read
22825
+ * out, the PII rule; the supabase subclass overrides with the GoTrue
22826
+ * auth.users allowlist). */
22456
22827
  async *readAuthUsers(cursor) {
22457
22828
  const source = await this.introspect();
22458
22829
  if (!source.authUsers?.present || !source.authUsers.table) {
22459
22830
  throw new Error("no auth table configured \u2014 pass authTable (--auth-table) for generic-pg sources");
22460
22831
  }
22461
22832
  const t = await this.resolveTable(source.authUsers.table);
22462
- const safe = t.columns.map((c) => c.name).filter((c) => !PASSWORDISH_RE2.test(c));
22463
- yield* this.pagedRead(t, safe, cursor);
22833
+ yield* this.pagedRead(t, this.identityColumns(t).keep, cursor);
22464
22834
  }
22465
22835
  async close() {
22466
22836
  await this.sql.end({ timeout: 5 });
@@ -22493,6 +22863,7 @@ function pickAuthColumns(available) {
22493
22863
  const have = new Set(available);
22494
22864
  return AUTH_USERS_SAFE_COLUMNS.filter((c) => have.has(c));
22495
22865
  }
22866
+ var UTC_ISO = (col) => `to_char(${col} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
22496
22867
  var supabaseSql = {
22497
22868
  /** does this database carry the GoTrue auth schema? */
22498
22869
  authUsersProbe: () => ({
@@ -22525,7 +22896,7 @@ var supabaseSql = {
22525
22896
  const n = Math.max(1, Math.trunc(limit));
22526
22897
  const conds = [...after !== null ? ["id > $1::uuid"] : [], ...hasDeletedAt ? ["deleted_at IS NULL"] : []];
22527
22898
  return {
22528
- 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}`,
22529
22900
  params: after !== null ? [after] : []
22530
22901
  };
22531
22902
  },
@@ -22668,6 +23039,8 @@ var SupabaseAdapter = class extends GenericPgAdapter {
22668
23039
  const out = rows.map((r) => {
22669
23040
  const h = typeof r.encrypted_password === "string" && r.encrypted_password.length > 0 ? r.encrypted_password : null;
22670
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;
22671
23044
  if (subjectColumn !== null) row2.identities = links.get(String(r.id)) ?? [];
22672
23045
  return row2;
22673
23046
  });
@@ -23427,6 +23800,9 @@ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config fo
23427
23800
  compared.collections += Object.keys(cfg.cms.collections).length;
23428
23801
  });
23429
23802
  const { functions: _fnFeature, ...configFeatures } = cfg.features;
23803
+ for (const c of paymentsShadowChecks(cfg.features?.payments)) {
23804
+ console.error(`warning: ${c.detail}`);
23805
+ }
23430
23806
  const serverApiState = {};
23431
23807
  await runSection("config", async () => {
23432
23808
  if (!Object.keys(configFeatures).length) return;
@@ -24165,6 +24541,7 @@ async function runDoctor() {
24165
24541
  const pres = await api("GET", "/v1/config/payments");
24166
24542
  if (pres.status === 200) {
24167
24543
  checks.push(...paymentsWebhookSecretChecks(pres.body.data?.manifest));
24544
+ checks.push(...paymentsShadowChecks(pres.body.data?.manifest));
24168
24545
  }
24169
24546
  }
24170
24547
  } else {
@@ -26771,10 +27148,13 @@ vxil functions dev '${name}' stopped.`);
26771
27148
  function migrateUsage() {
26772
27149
  return `usage: vxil migrate <plan|schema|data|verify> [--dev]
26773
27150
  plan --from supabase|generic-pg|csv-json (--url <pg-url> | --dir <folder>)
26774
- [--skip a,b] [--include-empty] [--profile-table <table>|none] [--keep-source-id]
27151
+ [--skip a,b] [--include a,b] [--include-empty] [--owner <table>=<column>|none,\u2026]
27152
+ [--profile-table <table>|none] [--keep-source-id]
26775
27153
  introspect the source (read-only; no API key needed) \u2192 migrate/plan.json
26776
27154
  + migrate/vxil.config.suggested.ts + migrate/RESIDUALS.md
26777
- (credit seeding is opt-in: tables named in migrate/payments.json)
27155
+ (credit seeding is opt-in: tables named in migrate/payments.json; a table skipped only
27156
+ for its NAME prints "skip (confirm)" \u2014 --include loads it; a table with an id-like
27157
+ column but no detected owner must be decided with --owner before schema --apply)
26778
27158
  schema [--apply] create the plan's cms + vector-search collections (default: dry-run diff; additive only)
26779
27159
  data [--execute] [--only users,credentials,cms,vectors,files,payments|<table>,\u2026] [--resume]
26780
27160
  the ordered backfill users \u2192 cms (FK order) \u2192 vectors \u2192 files \u2192 payments-credits
@@ -26868,7 +27248,14 @@ ${migrateUsage()}`);
26868
27248
  try {
26869
27249
  if (sub === "plan") {
26870
27250
  const skip = flag("skip")?.split(",").map((s) => s.trim()).filter(Boolean);
27251
+ const include = flag("include")?.split(",").map((s) => s.trim()).filter(Boolean);
26871
27252
  const profileTable = flag("profile-table");
27253
+ const owners = {};
27254
+ for (const pair of flag("owner")?.split(",").map((s) => s.trim()).filter(Boolean) ?? []) {
27255
+ const eq = pair.indexOf("=");
27256
+ if (eq <= 0 || eq === pair.length - 1) fail(`--owner '${pair}' must be <table>=<column> or <table>=none`);
27257
+ owners[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
27258
+ }
26872
27259
  await runMigratePlan({
26873
27260
  adapter,
26874
27261
  cwd,
@@ -26876,6 +27263,8 @@ ${migrateUsage()}`);
26876
27263
  sourceLabel: label,
26877
27264
  infer: {
26878
27265
  ...skip?.length ? { skipTables: skip } : {},
27266
+ ...include?.length ? { includeTables: include } : {},
27267
+ ...Object.keys(owners).length ? { owners } : {},
26879
27268
  ...hasFlag("include-empty") ? { includeEmpty: true } : {},
26880
27269
  ...profileTable !== void 0 ? { profileTable } : {},
26881
27270
  ...hasFlag("keep-source-id") ? { keepSourceId: true } : {}