@vxil/cli 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,26 @@ export declare function isOrgChannelTemplate(channel: string): boolean;
52
52
  * `org:<id>`). Null (publish nothing) for a non-template or an org id that is
53
53
  * not a string matching ORG_CHANNEL_ID_RE — fail closed. */
54
54
  export declare function resolveOrgChannel(template: string, orgId: unknown): string | null;
55
+ /** MX-1022-CDC-OWNER-CHANNEL: the owner template token — one WHOLE segment of a
56
+ * cdc `channel` on an owner-scoped (ownerField) collection. Each row publishes
57
+ * to its owner's private channel `user:<owner>:<rest>` (realtime-v1
58
+ * userChannelDenied binds an end-user token for a `user:` channel to its
59
+ * verified id). Never combined with `{org}` in one channel. */
60
+ export declare const CDC_OWNER_TOKEN = "{owner}";
61
+ /** Every resolved owner channel starts with this prefix (realtime-v1
62
+ * token.ts USER_CHANNEL_PREFIX; the `cdc-gated-fields` gate pins both equal). */
63
+ export declare const USER_CHANNEL_PREFIX = "user:";
64
+ /** An owner id that may enter a channel name: no ':' / '@' (realtime refuses
65
+ * such ids every `user:` channel anyway), ≤ 128 chars. */
66
+ export declare const OWNER_CHANNEL_ID_RE: RegExp;
67
+ /** Is `channel` an owner template — exactly one segment equal to `{owner}`,
68
+ * every other segment a non-empty channel-alphabet run, ≤ 128 chars? */
69
+ export declare function isOwnerChannelTemplate(channel: string): boolean;
70
+ /** Resolve an owner template for one row: drop the `{owner}` segment and prefix
71
+ * `user:<id>:` (`meals:{owner}` → `user:<id>:meals`). Null (publish nothing)
72
+ * for a non-template, an owner id that is not a string matching
73
+ * OWNER_CHANNEL_ID_RE, or a result past realtime's 200-char cap — fail closed. */
74
+ export declare function resolveOwnerChannel(template: string, ownerId: unknown): string | null;
55
75
  /** Cross-field rule for CmsConfig.cdc (called from validateFeatureConfig). */
56
76
  export declare function validateCdcConfig(cdc: Record<string, CdcRuleLike> | undefined): string[];
57
77
  /** What a payload:'full' change-data rule must know about its target
@@ -90,4 +110,14 @@ export declare function cdcFullFramesOverGatedCollections(cdc: unknown, collecti
90
110
  * Pure; one problem line per offending ENABLED rule.
91
111
  */
92
112
  export declare function cdcFixedChannelsOverOrgCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
113
+ /**
114
+ * MX-1022-CDC-OWNER-CHANNEL: an `{owner}` rule is valid only on an owner-scoped
115
+ * collection (a live `ownerField`) that is NOT a workspace collection: a
116
+ * `user:` channel skips realtime's active-org check, so a member removed from a
117
+ * workspace who still owns rows there would keep receiving them — `{org}`
118
+ * serves workspace collections. The cms runtime floor (cdc.ts cdcChannelFor)
119
+ * publishes nothing for such a rule at every door that cannot check. Pure; one
120
+ * problem line per offending ENABLED rule (an unknown collection is skipped).
121
+ */
122
+ export declare function cdcOwnerRulesOverNonOwnerCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
93
123
  export {};
@@ -105,6 +105,7 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
105
105
  html: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
106
106
  text: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
107
107
  }>>>>;
108
+ retainSentRender: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
108
109
  }>>;
109
110
  /** in-app inbox channel (send with channel: 'inbox' | 'both') */
110
111
  inboxEnabled: import("@sinclair/typebox").TBoolean;
@@ -313,6 +314,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
313
314
  resendCooldownSec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
314
315
  }>>;
315
316
  registryAdopt: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
317
+ userIdFormat: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"ulid">, import("@sinclair/typebox").TLiteral<"uuid">]>>;
316
318
  otp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
317
319
  enabled: import("@sinclair/typebox").TBoolean;
318
320
  codeTtlMinutes: import("@sinclair/typebox").TInteger;
@@ -615,6 +617,13 @@ export declare const DeclaredSearchCollectionSchema: import("@sinclair/typebox")
615
617
  orgScoped: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
616
618
  }>;
617
619
  export type DeclaredSearchCollection = Static<typeof DeclaredSearchCollectionSchema>;
620
+ /** VECTOR-HYBRID-LANE-WEIGHTS: the two RRF lane weights (weighted RRF —
621
+ * each lane's 1/(k+rank) is scaled by its weight). 0..100 each; a request
622
+ * with both 0 is refused at query time. */
623
+ export declare const HybridLaneWeights: import("@sinclair/typebox").TObject<{
624
+ vector: import("@sinclair/typebox").TNumber;
625
+ keyword: import("@sinclair/typebox").TNumber;
626
+ }>;
618
627
  export declare const VectorSearchConfigSchema: import("@sinclair/typebox").TObject<{
619
628
  enabled: import("@sinclair/typebox").TBoolean;
620
629
  backend: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"auto">, import("@sinclair/typebox").TLiteral<"lakebase">, import("@sinclair/typebox").TLiteral<"pgvector">]>;
@@ -628,10 +637,14 @@ export declare const VectorSearchConfigSchema: import("@sinclair/typebox").TObje
628
637
  maxTokens: import("@sinclair/typebox").TInteger;
629
638
  overlap: import("@sinclair/typebox").TInteger;
630
639
  }>;
631
- hybrid: import("@sinclair/typebox").TObject<{
640
+ hybrid: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
632
641
  defaultMode: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"hybrid">, import("@sinclair/typebox").TLiteral<"vector">, import("@sinclair/typebox").TLiteral<"keyword">]>;
633
642
  rrfK: import("@sinclair/typebox").TInteger;
634
- }>;
643
+ weights: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
644
+ vector: import("@sinclair/typebox").TNumber;
645
+ keyword: import("@sinclair/typebox").TNumber;
646
+ }>>;
647
+ }>>;
635
648
  limits: import("@sinclair/typebox").TObject<{
636
649
  maxDocsPerCollection: import("@sinclair/typebox").TInteger;
637
650
  maxChunksPerDoc: import("@sinclair/typebox").TInteger;
@@ -691,6 +704,7 @@ export declare const AiConfigSchema: import("@sinclair/typebox").TObject<{
691
704
  maxTokens: import("@sinclair/typebox").TInteger;
692
705
  temperature: import("@sinclair/typebox").TNumber;
693
706
  thinkingLevel: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<import("@sinclair/typebox").TLiteral<"minimal" | "low" | "medium" | "high">[]>>;
707
+ mediaResolution: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"medium">, import("@sinclair/typebox").TLiteral<"high">]>>;
694
708
  }>>;
695
709
  cache: import("@sinclair/typebox").TObject<{
696
710
  ttlSeconds: import("@sinclair/typebox").TInteger;
@@ -710,6 +724,7 @@ export declare const AiConfigSchema: import("@sinclair/typebox").TObject<{
710
724
  system: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
711
725
  schema: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnknown>>;
712
726
  }>>>;
727
+ testMarkers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
713
728
  }>;
714
729
  export type AiConfig = Static<typeof AiConfigSchema>;
715
730
  export declare const RagConfigSchema: import("@sinclair/typebox").TObject<{
@@ -805,6 +820,13 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
805
820
  deferDowngrades: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
806
821
  subPeriodGrants: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
807
822
  }>>;
823
+ refunds: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
824
+ cancelAtProvider: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"off">, import("@sinclair/typebox").TLiteral<"period_end">, import("@sinclair/typebox").TLiteral<"immediately">]>>;
825
+ }>>;
826
+ disputes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
827
+ cancelAtProvider: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"off">, import("@sinclair/typebox").TLiteral<"period_end">]>>;
828
+ }>>;
829
+ shadow: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
808
830
  }>;
809
831
  export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
810
832
  export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<{
@@ -916,12 +938,12 @@ export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
916
938
  /** The events a `cmsHook` function binding may name — the closed union
917
939
  * `packages/config` types on the `cmsHook` trigger, and the keys of the
918
940
  * functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
919
- * beforeUpdate → updated, beforeWrite → both, afterPublish → published). Omitted = beforeWrite. Anything
941
+ * beforeUpdate → updated, beforeWrite → both, afterPublish → published, afterDelete → deleted). Omitted = beforeWrite. Anything
920
942
  * else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
921
943
  * to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
922
944
  * 'beforeDelete' silently subscribed the function to creates AND updates.
923
945
  * A CI gate pins every copy to this list. */
924
- export declare const CMS_HOOK_EVENTS: readonly ["beforeCreate", "beforeUpdate", "beforeWrite", "afterPublish"];
946
+ export declare const CMS_HOOK_EVENTS: readonly ["beforeCreate", "beforeUpdate", "beforeWrite", "afterPublish", "afterDelete"];
925
947
  export type CmsHookEvent = (typeof CMS_HOOK_EVENTS)[number];
926
948
  /** The ONE refusal text for an unknown cmsHook binding event (the config
927
949
  * validator and the deploy route's early 422 both use it). */
@@ -1014,3 +1036,18 @@ export declare function isDeclaredRetainDays(v: unknown): v is number | null | u
1014
1036
  * collection that change applies only under allow-destructive; lengthening
1015
1037
  * or clearing keeps more and always applies. */
1016
1038
  export declare function retainDaysShortens(declared: number | null, remote: number | null): boolean;
1039
+ /** = cms-v1 core.ts DELETED_RETAIN_DAYS_MAX (a parity test pins the two). */
1040
+ export declare const CMS_DELETED_RETAIN_DAYS_MAX = 29;
1041
+ /** = cms-v1 core.ts DELETED_RETAIN_DAYS_DEFAULT: the platform window. Writing
1042
+ * it explicitly is accepted and stored as null (MX-1023-SMALL — the
1043
+ * documented default must never be a 422). */
1044
+ export declare const CMS_DELETED_RETAIN_DAYS_DEFAULT = 30;
1045
+ export declare function isDeclaredDeletedRetainDays(v: unknown): v is number | null | undefined;
1046
+ /** The stored form of a declared window: the explicit default (30) is null,
1047
+ * so a push declaring 30 against a null remote plans nothing. */
1048
+ export declare function normalizeDeletedRetainDays<T extends number | null | undefined>(v: T): T | null;
1049
+ export declare function isDeclaredOnOwnerErase(v: unknown): v is 'delete' | 'keep' | undefined;
1050
+ /** Does moving from `remote` to `declared` make an erase delete MORE (turning
1051
+ * 'delete' on)? On an existing collection that applies only under
1052
+ * allow-destructive; turning it off always applies. */
1053
+ export declare function onOwnerEraseWidens(declared: 'delete' | 'keep', remote: 'delete' | 'keep'): boolean;
package/dist/config.d.ts CHANGED
@@ -140,6 +140,24 @@ export interface CollectionDef {
140
140
  * shortening it needs `vxil push --allow-destructive` (lengthening or
141
141
  * `null` always applies). Carried by BOTH push paths. */
142
142
  retainDays?: number | null;
143
+ /** Recovery window for DELETED items, in days (guide ch. 4): a soft-deleted
144
+ * item stays restorable this long before the nightly cleanup purges it for
145
+ * good. An integer 0..29 (0 = purged at the next nightly run, ≤ 24 h), or
146
+ * `null` (or `30`, stored as null) for the platform default of 30 days. ABSENT = not managed by
147
+ * config. Shortening it on an existing collection needs
148
+ * `vxil push --allow-destructive`. Carried by BOTH push paths. */
149
+ deletedRetainDays?: number | null;
150
+ /** What a GDPR erase of a user does to the items they OWN here (guide ch. 9;
151
+ * needs `ownerField` — "owner" is whoever that field names):
152
+ * 'keep' (default) — the items stay; the owner id resolves to the
153
+ * erased tombstone user;
154
+ * 'delete' — the erase hard-deletes them (live and deleted, unique values
155
+ * freed) in the same transaction. Relations elsewhere are not
156
+ * cascaded (they keep a dangling id).
157
+ * ABSENT = not managed by config. Turning 'delete' on for an existing
158
+ * collection needs `vxil push --allow-destructive`. Carried by BOTH push
159
+ * paths. */
160
+ onOwnerErase?: 'delete' | 'keep';
143
161
  /** Per-record action buttons (guide ch. 4): `[{ key, label, fn }]`, stored on
144
162
  * the collection (model data, not a config leaf). The dashboard renders one
145
163
  * button per action on each record row; `POST /v1/cms/items/:coll/:id/actions/
@@ -303,12 +321,14 @@ export type FunctionTrigger =
303
321
  * `beforeUpdate` → updated only, `beforeWrite` → created|updated,
304
322
  * `afterPublish` → published (a draft→published transition is its own event,
305
323
  * not an update); a write to another collection never wakes the function.
306
- * Deleted/bulk events are not delivered to a cmsHook binding. `event`
324
+ * `afterDelete` → deleted (one event per deleted root row — cascaded children
325
+ * and GDPR erase emit none; the payload carries `id`, `owner` (+ `org`), never
326
+ * the row body). Bulk-summary events are not delivered to a cmsHook binding. `event`
307
327
  * defaults to `beforeWrite`. */
308
328
  | {
309
329
  kind: 'cmsHook';
310
330
  collection: string;
311
- event?: 'beforeCreate' | 'beforeUpdate' | 'beforeWrite' | 'afterPublish';
331
+ event?: 'beforeCreate' | 'beforeUpdate' | 'beforeWrite' | 'afterPublish' | 'afterDelete';
312
332
  retry?: FunctionTriggerRetry;
313
333
  } | {
314
334
  kind: 'authHook';
package/dist/vxil-main.js CHANGED
@@ -3699,7 +3699,14 @@ var NotificationsConfigSchema = Type.Object({
3699
3699
  html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
3700
3700
  text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX }))
3701
3701
  }))
3702
- ))
3702
+ )),
3703
+ // CVS-1022-1 (2026-10-23): keep the render of a REAL send on its delivery
3704
+ // row for 7 days (GET /v1/notifications/deliveries/{id}, rendered_from
3705
+ // send | send_redacted). Opt-in, no default (absent = off, manifests stay
3706
+ // byte-identical); auth-class mail is kept WITHOUT its data; a render
3707
+ // over 64 KiB is dropped (row kept). Inside the ONE optional templates
3708
+ // leaf — the schema is at the 15-leaf cap.
3709
+ retainSentRender: Type.Optional(Type.Boolean())
3703
3710
  },
3704
3711
  { default: {} }
3705
3712
  )),
@@ -4004,6 +4011,13 @@ var AuthConfigSchema = Type.Object({
4004
4011
  // of security semantics for a tenant that bulk-imports contacts, whose
4005
4012
  // addresses vxil never verified.
4006
4013
  registryAdopt: Type.Optional(Type.Boolean()),
4014
+ // MX-1022-USER-ID-UUID (2026-10-22): the shape of NEWLY minted user ids.
4015
+ // 'ulid' ⇒ `user_<ulid>` (the historical shape); 'uuid' ⇒ a bare RFC 4122 v4
4016
+ // uuid (crypto.randomUUID), for tenants whose own schema keys users by uuid.
4017
+ // Optional with NO default: absent ⇒ 'ulid' (auth-v1 `mintUserId` owns the
4018
+ // default), so existing manifests stay byte-identical. Existing ids are never
4019
+ // rewritten — flipping it only changes users created afterwards.
4020
+ userIdFormat: Type.Optional(Type.Union([Type.Literal("ulid"), Type.Literal("uuid")])),
4007
4021
  // Email OTP sign-in + the knobs step-up re-auth shares.
4008
4022
  // OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
4009
4023
  // otp?.enabled === true).
@@ -4591,6 +4605,10 @@ var DeclaredSearchCollectionSchema = Type.Object({
4591
4605
  // filtered to the active org; immutable after create (inside the array leaf).
4592
4606
  orgScoped: Type.Optional(Type.Boolean())
4593
4607
  });
4608
+ var HybridLaneWeights = Type.Object({
4609
+ vector: Type.Number({ minimum: 0, maximum: 100 }),
4610
+ keyword: Type.Number({ minimum: 0, maximum: 100 })
4611
+ }, { additionalProperties: false });
4594
4612
  var VectorSearchConfigSchema = Type.Object({
4595
4613
  enabled: Type.Boolean({ default: true }),
4596
4614
  // 'auto' resolves to the default managed vector backend for the tier.
@@ -4626,16 +4644,22 @@ var VectorSearchConfigSchema = Type.Object({
4626
4644
  },
4627
4645
  { default: {} }
4628
4646
  ),
4629
- hybrid: Type.Object(
4647
+ // The hybrid-fusion knobs — ONE optional bag (VECTOR-HYBRID-LANE-WEIGHTS
4648
+ // folded it from 2 leaves to 1, so `weights` rides it at no leaf cost);
4649
+ // `{ default: {} }` keeps defaultMode / rrfK filled exactly as before.
4650
+ hybrid: Type.Optional(Type.Object(
4630
4651
  {
4631
4652
  defaultMode: Type.Union(
4632
4653
  [Type.Literal("hybrid"), Type.Literal("vector"), Type.Literal("keyword")],
4633
4654
  { default: "hybrid" }
4634
4655
  ),
4635
- rrfK: Type.Integer({ default: 60, minimum: 1, maximum: 1e3 })
4656
+ rrfK: Type.Integer({ default: 60, minimum: 1, maximum: 1e3 }),
4657
+ // Per-lane RRF weights for hybrid queries (absent = 1 / 1, today's
4658
+ // fusion); a request's `weights` overrides. At least one must be > 0.
4659
+ weights: Type.Optional(HybridLaneWeights)
4636
4660
  },
4637
4661
  { default: {} }
4638
- ),
4662
+ )),
4639
4663
  limits: Type.Object(
4640
4664
  {
4641
4665
  maxDocsPerCollection: Type.Integer({ default: 1e5, minimum: 1 }),
@@ -4743,7 +4767,11 @@ var AiConfigSchema = Type.Object({
4743
4767
  model: Type.String({ default: "mock-1" }),
4744
4768
  maxTokens: Type.Integer({ default: 1024, minimum: 1, maximum: 2e5 }),
4745
4769
  temperature: Type.Number({ default: 0.7, minimum: 0, maximum: 2 }),
4746
- thinkingLevel: Type.Optional(Type.Union(AI_THINKING_LEVELS.map((l) => Type.Literal(l))))
4770
+ thinkingLevel: Type.Optional(Type.Union(AI_THINKING_LEVELS.map((l) => Type.Literal(l)))),
4771
+ // MX-1023-MEDIA-RESOLUTION (2026-10-23): Gemini's per-image / per-page
4772
+ // token budget, sent to Gemini only (ignored elsewhere). No default, so an
4773
+ // existing manifest stays byte-identical; inside the bag, so no new leaf.
4774
+ mediaResolution: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")]))
4747
4775
  }, { default: {} })),
4748
4776
  cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }),
4749
4777
  // 0 = off
@@ -4779,7 +4807,16 @@ var AiConfigSchema = Type.Object({
4779
4807
  // in place — RETIRED (soft: hidden from list + render, history kept) only
4780
4808
  // under --allow-destructive (2026-10-01). Bounded to 50 entries: the
4781
4809
  // 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 }))
4810
+ templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
4811
+ // Test markers: honour the `[[provider_fail]]` /
4812
+ // `[[provider_fail:retryable]]` prompt marker on a REAL provider, so a staging
4813
+ // project can test its failure handling (the run ends FAILED, hold released,
4814
+ // `job.generation.failed` / ProviderFailed, no provider call, no cost). The
4815
+ // mock provider always honours it. No default: absent = off, so existing
4816
+ // manifests are byte-identical. Config-only and versioned (visible in
4817
+ // `vxil diff`); deliberately never a request header. Set it in a staging
4818
+ // config only.
4819
+ testMarkers: Type.Optional(Type.Boolean())
4783
4820
  });
4784
4821
  var RagConfigSchema = Type.Object({
4785
4822
  enabled: Type.Boolean({ default: true }),
@@ -5054,7 +5091,38 @@ var PaymentsConfigSchema = Type.Object({
5054
5091
  // sized a yearly plan's `amount` for the year would mint it every month.
5055
5092
  // No schema default; the reader treats absent as false.
5056
5093
  subPeriodGrants: Type.Optional(Type.Boolean())
5057
- }))
5094
+ })),
5095
+ // (2026-10-22 PAY-REFUND-CANCEL-OPTIN) The tenant default for `POST
5096
+ // /v1/payments/refunds` calls that send no `cancel`: also stop the provider
5097
+ // subscription the refunded charge paid for, once the refund has settled.
5098
+ // off (default): refund and cancel stay separate (the provider keeps billing
5099
+ // until DELETE /subscriptions/{id}); period_end; immediately (Stripe only —
5100
+ // elsewhere it runs as period_end). A per-call `cancel` always wins. No
5101
+ // schema default (Value.Default would materialize it into every manifest);
5102
+ // the reader owns `off`.
5103
+ refunds: Type.Optional(Type.Object({
5104
+ cancelAtProvider: Type.Optional(Type.Union([
5105
+ Type.Literal("off"),
5106
+ Type.Literal("period_end"),
5107
+ Type.Literal("immediately")
5108
+ ]))
5109
+ })),
5110
+ // (2026-10-22 PAY-REFUND-CANCEL-OPTIN, dispute half) When a charge is
5111
+ // disputed vxil ends the subscription row, but the provider keeps billing.
5112
+ // period_end: also schedule the provider subscription to stop at period end
5113
+ // (Stripe + mock only; elsewhere a recorded no-op). No `immediately`: a won
5114
+ // dispute reinstates the row, and an immediate cancel can't be undone. Absent
5115
+ // ⇒ off (the reader owns the default).
5116
+ disputes: Type.Optional(Type.Object({
5117
+ cancelAtProvider: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("period_end")]))
5118
+ })),
5119
+ // (2026-10-22 PAYMENTS-SHADOW) Run vxil beside an existing billing system:
5120
+ // webhooks still fold into vxil's state (and ledger tier grants still run),
5121
+ // but the business `payments.*` events (subscription/charge/entitlement/
5122
+ // grant) are not written, so outbound webhooks and function triggers stay
5123
+ // quiet. Diagnostics (webhook.rejected, webhook_event.*, reconcile.*,
5124
+ // conformance, refund.failed) still flow. Absent ⇒ false.
5125
+ shadow: Type.Optional(Type.Boolean())
5058
5126
  // NB: the former `webhooks.forwardToTenantUrl` leaf
5059
5127
  // was DELETED — it had zero readers (never forwarded anything). Outbound
5060
5128
  // delivery of payments state changes rides the audit stream → outbound
@@ -5144,7 +5212,7 @@ var FunctionsConfigSchema = Type.Object({
5144
5212
  collection: Type.Optional(Type.String()),
5145
5213
  // cmsHook: the CMS collection slug
5146
5214
  event: Type.Optional(Type.String()),
5147
- // cmsHook: beforeCreate|beforeUpdate|beforeWrite|afterPublish · authHook: 'user.created'
5215
+ // cmsHook: beforeCreate|beforeUpdate|beforeWrite|afterPublish|afterDelete · authHook: 'user.created'
5148
5216
  // (2026-09-25) the per-binding opt-in to re-delivery on
5149
5217
  // queue / webhook / cmsHook / authHook, and since 2026-10-03 on
5150
5218
  // cron and http (the async lane only). Absent = the ACK-200 default. The
@@ -5364,6 +5432,20 @@ function retainDaysShortens(declared, remote) {
5364
5432
  if (declared === null) return false;
5365
5433
  return remote === null || declared < remote;
5366
5434
  }
5435
+ var CMS_DELETED_RETAIN_DAYS_MAX = 29;
5436
+ var CMS_DELETED_RETAIN_DAYS_DEFAULT = 30;
5437
+ function isDeclaredDeletedRetainDays(v) {
5438
+ return v === void 0 || v === null || typeof v === "number" && Number.isInteger(v) && v >= 0 && (v <= CMS_DELETED_RETAIN_DAYS_MAX || v === CMS_DELETED_RETAIN_DAYS_DEFAULT);
5439
+ }
5440
+ function normalizeDeletedRetainDays(v) {
5441
+ return v === CMS_DELETED_RETAIN_DAYS_DEFAULT ? null : v;
5442
+ }
5443
+ function isDeclaredOnOwnerErase(v) {
5444
+ return v === void 0 || v === "delete" || v === "keep";
5445
+ }
5446
+ function onOwnerEraseWidens(declared, remote) {
5447
+ return declared === "delete" && remote !== "delete";
5448
+ }
5367
5449
 
5368
5450
  // ../config/src/index.ts
5369
5451
  var CMS_ROLE_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
@@ -5584,9 +5666,9 @@ function lowerAllTriggerBindings(def, name) {
5584
5666
  return bindings;
5585
5667
  }
5586
5668
  var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
5587
- var VXIL_CONFIG_PKG_VERSION = "0.20.0";
5588
- var VXIL_SDK_PKG_VERSION = "0.27.0";
5589
- var VXIL_CLI_PKG_VERSION = "0.28.0";
5669
+ var VXIL_CONFIG_PKG_VERSION = "0.22.0";
5670
+ var VXIL_SDK_PKG_VERSION = "0.29.0";
5671
+ var VXIL_CLI_PKG_VERSION = "0.30.0";
5590
5672
  function ensureScaffoldPackageJson(cwd, opts = {}) {
5591
5673
  const file = resolve(cwd, "package.json");
5592
5674
  const wanted = {
@@ -9696,6 +9778,8 @@ function remoteCollectionOf(c) {
9696
9778
  orgField: typeof c.org_field === "string" && c.org_field.length > 0 ? c.org_field : null,
9697
9779
  orgReadRoles: writeRolesOf(c.org_read_roles),
9698
9780
  retainDays: typeof c.retain_days === "number" ? c.retain_days : null,
9781
+ deletedRetainDays: typeof c.deleted_retain_days === "number" ? c.deleted_retain_days : null,
9782
+ onOwnerErase: c.on_owner_erase === "delete" ? "delete" : "keep",
9699
9783
  actions: normalizeActions(c.actions),
9700
9784
  fields: Array.isArray(c.fields) ? c.fields : []
9701
9785
  };
@@ -9713,6 +9797,8 @@ function pulledCollectionBlock(c) {
9713
9797
  ...rc.orgField ? { orgField: rc.orgField } : {},
9714
9798
  ...rc.orgReadRoles.length ? { orgReadRoles: rc.orgReadRoles } : {},
9715
9799
  ...rc.retainDays !== null ? { retainDays: rc.retainDays } : {},
9800
+ ...rc.deletedRetainDays !== null ? { deletedRetainDays: rc.deletedRetainDays } : {},
9801
+ ...rc.onOwnerErase === "delete" ? { onOwnerErase: "delete" } : {},
9716
9802
  ...rc.actions.length ? { actions: rc.actions } : {},
9717
9803
  fields
9718
9804
  };
@@ -9783,6 +9869,14 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
9783
9869
  if (!isDeclaredRetainDays(rd)) {
9784
9870
  throw new Error(`cms.collections.${name}.retainDays must be an integer 1..3650 (days) or null (got ${JSON.stringify(rd)})`);
9785
9871
  }
9872
+ const drd = def.deletedRetainDays;
9873
+ if (!isDeclaredDeletedRetainDays(drd)) {
9874
+ throw new Error(`cms.collections.${name}.deletedRetainDays must be an integer 0..29 (days), or 30 / null for the default (got ${JSON.stringify(drd)})`);
9875
+ }
9876
+ const ooe = def.onOwnerErase;
9877
+ if (!isDeclaredOnOwnerErase(ooe)) {
9878
+ throw new Error(`cms.collections.${name}.onOwnerErase must be 'delete' or 'keep' (got ${JSON.stringify(ooe)})`);
9879
+ }
9786
9880
  }
9787
9881
  const read = await readRemote2(api);
9788
9882
  if (read.unavailable) {
@@ -10020,6 +10114,33 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
10020
10114
  });
10021
10115
  }
10022
10116
  }
10117
+ const declaredWindow = normalizeDeletedRetainDays(def.deletedRetainDays);
10118
+ if (declaredWindow !== void 0 && declaredWindow !== rc.deletedRetainDays) {
10119
+ const fmtWin = (d) => d === null ? "30 days (default)" : `${d} days`;
10120
+ const shortens = retainDaysShortens(declaredWindow, rc.deletedRetainDays);
10121
+ collChanges.push(!shortens || allowDestructive ? {
10122
+ collection: name,
10123
+ kind: "set-deleted-retain-days",
10124
+ detail: `${name} deletedRetainDays \u2192 ${fmtWin(declaredWindow)} (was ${fmtWin(rc.deletedRetainDays)})` + (shortens ? " \u2014 deleted items are purged sooner" : "")
10125
+ } : {
10126
+ collection: name,
10127
+ kind: "warn",
10128
+ 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.`
10129
+ });
10130
+ }
10131
+ const declaredErase = def.onOwnerErase;
10132
+ if (declaredErase !== void 0 && declaredErase !== rc.onOwnerErase) {
10133
+ const widens = onOwnerEraseWidens(declaredErase, rc.onOwnerErase);
10134
+ collChanges.push(!widens || allowDestructive ? {
10135
+ collection: name,
10136
+ kind: "set-on-owner-erase",
10137
+ detail: `${name} onOwnerErase \u2192 ${declaredErase} (was ${rc.onOwnerErase})` + (widens ? " \u2014 a user's erase then hard-deletes the items they own" : "")
10138
+ } : {
10139
+ collection: name,
10140
+ kind: "warn",
10141
+ 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.`
10142
+ });
10143
+ }
10023
10144
  const declaredActions = normalizeActions(def.actions);
10024
10145
  if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
10025
10146
  collChanges.push({
@@ -10099,6 +10220,9 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
10099
10220
  const orgRoles = writeRolesOf(def.orgReadRoles);
10100
10221
  if (orgRoles.length > 0) body.org_read_roles = orgRoles;
10101
10222
  if (typeof def.retainDays === "number") body.retain_days = def.retainDays;
10223
+ const createWindow = normalizeDeletedRetainDays(def.deletedRetainDays);
10224
+ if (typeof createWindow === "number") body.deleted_retain_days = createWindow;
10225
+ if (def.onOwnerErase === "delete") body.on_owner_erase = "delete";
10102
10226
  if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
10103
10227
  const res = await api("POST", "/v1/cms/collections", body);
10104
10228
  if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
@@ -10180,6 +10304,15 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
10180
10304
  throw new Error(`cms: set retainDays on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
10181
10305
  }
10182
10306
  applied++;
10307
+ } else if (c.kind === "set-deleted-retain-days" || c.kind === "set-on-owner-erase") {
10308
+ const def = collections[c.collection];
10309
+ const body = c.kind === "set-deleted-retain-days" ? { deleted_retain_days: normalizeDeletedRetainDays(def.deletedRetainDays) ?? null } : { on_owner_erase: def.onOwnerErase ?? "keep" };
10310
+ const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, body);
10311
+ if (res.status !== 200 && res.status !== 201) {
10312
+ const e = res.body.error ?? {};
10313
+ throw new Error(`cms: set ${Object.keys(body)[0]} on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
10314
+ }
10315
+ applied++;
10183
10316
  } else if (c.kind === "set-actions") {
10184
10317
  const target = normalizeActions(collections[c.collection].actions);
10185
10318
  const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
@@ -10245,7 +10378,7 @@ function formatCmsChanges(changes) {
10245
10378
  if (c.kind === "destructive") return ` ! ${c.detail}`;
10246
10379
  if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
10247
10380
  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}`;
10381
+ 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
10382
  return ` + ${c.detail}`;
10250
10383
  }).join("\n");
10251
10384
  }
@@ -12202,7 +12335,8 @@ var CMS_HOOK_EVENTS_FOR = {
12202
12335
  beforeCreate: ["cms.item.created"],
12203
12336
  beforeUpdate: ["cms.item.updated"],
12204
12337
  beforeWrite: ["cms.item.created", "cms.item.updated"],
12205
- afterPublish: ["cms.item.published"]
12338
+ afterPublish: ["cms.item.published"],
12339
+ afterDelete: ["cms.item.deleted"]
12206
12340
  };
12207
12341
  function cmsHookBindingMatches(bindings, event, collection) {
12208
12342
  const hooks = (bindings ?? []).filter((b2) => b2.kind === "cmsHook" || b2.kind === "cms-hook");
@@ -12759,7 +12893,7 @@ async function importNdjson(api, feature, pieces, mode, opts = {}) {
12759
12893
  }
12760
12894
  tables.set(t.table, acc);
12761
12895
  }
12762
- for (const k of ["warnings", "ignored_tables", "replace_retained", "truncated_tables"]) {
12896
+ for (const k of ["warnings", "ignored_tables", "notes", "replace_retained", "truncated_tables"]) {
12763
12897
  if (Array.isArray(d[k])) for (const s of d[k]) {
12764
12898
  const set = extras.get(k) ?? /* @__PURE__ */ new Set();
12765
12899
  set.add(String(s));
@@ -13142,7 +13276,7 @@ var TOOLS = [
13142
13276
  {
13143
13277
  name: "notifications_send",
13144
13278
  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?).",
13279
+ 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
13280
  inputSchema: {
13147
13281
  type: "object",
13148
13282
  properties: {
@@ -14021,7 +14155,7 @@ var TOOLS = [
14021
14155
  {
14022
14156
  name: "cms_list_collections",
14023
14157
  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.",
14158
+ 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
14159
  inputSchema: { type: "object", properties: {} },
14026
14160
  method: "GET",
14027
14161
  path: "/v1/cms/collections"
@@ -14744,6 +14878,13 @@ var TOOLS = [
14744
14878
  mode: { type: "string", enum: ["hybrid", "vector", "keyword"], default: "hybrid" },
14745
14879
  filter: { type: "object", additionalProperties: true, description: 'Metadata filter, keys ANDed: equality {"user_id":"u_42"} or any-of {"folder":{"$in":["a","b"]}} (1\u201350 values, \u22644 $in keys). null / arrays / other operators are refused 422.' },
14746
14880
  rerank: { type: "boolean", description: "Apply the configured BYO reranker (config rerank block) to the fused results before top-k. Fail-open: a provider fault returns the RRF order with reranked:false." },
14881
+ weights: {
14882
+ type: "object",
14883
+ additionalProperties: false,
14884
+ required: ["vector", "keyword"],
14885
+ properties: { vector: { type: "number", minimum: 0, maximum: 100 }, keyword: { type: "number", minimum: 0, maximum: 100 } },
14886
+ description: `Hybrid mode only: per-lane RRF weights, e.g. {"vector":0.8,"keyword":0.2} to favour meaning over exact words. Not both 0. Default: the config's hybrid.weights, else equal.`
14887
+ },
14747
14888
  org_id: { type: "string", description: "Workspace-scoped (org_scoped) collections only: the org to search. Required with a server key (422 org_id_required without); an end-user session always searches its active workspace. Refused 422 on a collection that is not org-scoped." }
14748
14889
  },
14749
14890
  required: ["collection", "query"]
@@ -14754,7 +14895,7 @@ var TOOLS = [
14754
14895
  {
14755
14896
  name: "ai_generate",
14756
14897
  feature: "ai",
14757
- description: "Generate a completion from a prompt or a named tenant template. Pass either prompt (raw text) or template + input (vars for a stored template). Defaults to the tenant-configured provider/model; override with provider/model. response_schema makes the output schema-guaranteed JSON (parsed into data.output). Returns the synchronous completion (set stream only via the REST API).",
14898
+ description: "Generate a completion from a prompt or a named tenant template. Pass either prompt (raw text) or template + input (vars for a stored template). Defaults to the tenant-configured provider/model; override with provider/model. response_schema makes the output schema-guaranteed JSON (parsed into data.output). Returns the synchronous completion by default (set stream only via the REST API); mode:'job' instead returns a 202 handle (generation_id, run_id) \u2014 read the answer with ai_generation_get and wait:25.",
14758
14899
  inputSchema: {
14759
14900
  type: "object",
14760
14901
  properties: {
@@ -14770,6 +14911,22 @@ var TOOLS = [
14770
14911
  enum: ["minimal", "low", "medium", "high"],
14771
14912
  description: "Gemini thinking depth (ignored by other providers). Omitted: the tenant's defaults.thinkingLevel, then the model default. 'minimal' on a model that rejects it is a 422 thinking_level_unsupported. Thinking tokens bill as output."
14772
14913
  },
14914
+ media_resolution: {
14915
+ type: "string",
14916
+ enum: ["low", "medium", "high"],
14917
+ description: "Gemini token budget per image or PDF page (ignored by other providers): lower is faster and bills fewer input tokens. Omitted: the tenant's defaults.mediaResolution, then the model default."
14918
+ },
14919
+ mode: {
14920
+ type: "string",
14921
+ enum: ["sync", "job"],
14922
+ description: "'sync' (default) answers with the completion. 'job' routes the generation through a jobs run and answers 202 with generation_id + run_id; read it with ai_generation_get (wait:25)."
14923
+ },
14924
+ deadline_ms: {
14925
+ type: "integer",
14926
+ minimum: 1e3,
14927
+ maximum: 17e3,
14928
+ description: "mode:'job' only (422 otherwise): bound the run's first provider call at this many ms so it can start without the queue hop. A first call that passes it is retried on the queue with the normal job-lane bound."
14929
+ },
14773
14930
  json_mode: { type: "boolean", description: "Constrain the output to JSON (no schema \u2014 use response_schema for a guaranteed shape)." },
14774
14931
  response_schema: {
14775
14932
  type: "object",
@@ -14818,12 +14975,18 @@ var TOOLS = [
14818
14975
  type: "object",
14819
14976
  properties: {
14820
14977
  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." }
14978
+ correlation_id: { type: "string", minLength: 1, maxLength: 128, description: "The correlation_id you sent on ai_generate / the REST generate call." },
14979
+ wait: {
14980
+ type: "integer",
14981
+ minimum: 1,
14982
+ maximum: 25,
14983
+ 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)."
14984
+ }
14822
14985
  }
14823
14986
  },
14824
14987
  method: "GET",
14825
14988
  path: (a) => a.generation_id ? `/v1/ai/generations/${encodeURIComponent(String(a.generation_id))}` : "/v1/ai/generations",
14826
- queryArgs: ["correlation_id"]
14989
+ queryArgs: ["correlation_id", "wait"]
14827
14990
  },
14828
14991
  {
14829
14992
  name: "ai_classify",
@@ -15054,14 +15217,15 @@ var TOOLS = [
15054
15217
  properties: {
15055
15218
  since: { type: "string", description: "ISO-8601 lower bound on created_at (inclusive; no zone = UTC). Tail with an overlap (last stored created_at minus a minute) and dedupe on ledger_entry_id." },
15056
15219
  cursor: { type: "string", description: "Opaque keyset cursor from a prior page (next_cursor)." },
15057
- user_id: { type: "string", maxLength: 512, description: "Only this end user's rows." },
15220
+ user_id: { type: "string", maxLength: 512, description: "Only this end user's rows. Give this OR org_id." },
15221
+ org_id: { type: "string", maxLength: 64, description: "WORKSPACE BILLING: only this workspace's rows (an orgs id; the same as user_id 'org:<id>'). Give this OR user_id." },
15058
15222
  credit_type: { type: "string", maxLength: 200, description: "Only rows of this credit type, e.g. 'scans'." },
15059
15223
  limit: { type: "number", default: 100, description: "Page size, clamped 1..500." }
15060
15224
  }
15061
15225
  },
15062
15226
  method: "GET",
15063
15227
  path: "/v1/payments/credits/ledger",
15064
- queryArgs: ["since", "cursor", "user_id", "credit_type", "limit"]
15228
+ queryArgs: ["since", "cursor", "user_id", "org_id", "credit_type", "limit"]
15065
15229
  },
15066
15230
  {
15067
15231
  name: "payments_list_refunds",
@@ -15091,7 +15255,7 @@ var TOOLS = [
15091
15255
  // Substrate tool (no `feature` — always-on like users_upsert): usage is a
15092
15256
  // platform property, not a feature you enable.
15093
15257
  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.",
15258
+ 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
15259
  inputSchema: { type: "object", properties: {} },
15096
15260
  method: "GET",
15097
15261
  path: "/v1/usage"
@@ -15728,6 +15892,23 @@ function paymentsWebhookSecretChecks(manifest) {
15728
15892
  }
15729
15893
  return [{ name: "payments webhook secret", ok: true, warn: true, detail: `${provider}: ${PAYMENTS_WEBHOOK_SECRET_NOTE}` }];
15730
15894
  }
15895
+ 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";
15896
+ 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";
15897
+ 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";
15898
+ function paymentsShadowChecks(manifest) {
15899
+ if (!manifest || manifest.enabled === false) return [];
15900
+ const out = [];
15901
+ if (manifest.shadow === true) {
15902
+ out.push({ name: "payments shadow", ok: true, warn: true, detail: PAYMENTS_SHADOW_NOTE });
15903
+ const grants = Object.values(manifest.ledger?.tierMap ?? {}).some((t) => (t?.grants?.length ?? 0) > 0);
15904
+ if (grants) out.push({ name: "payments shadow grants", ok: true, warn: true, detail: PAYMENTS_SHADOW_GRANTS_NOTE });
15905
+ }
15906
+ const provider = String(manifest.provider ?? "");
15907
+ if (manifest.disputes?.cancelAtProvider === "period_end" && provider !== "stripe" && provider !== "mock") {
15908
+ out.push({ name: "payments dispute cancel", ok: true, warn: true, detail: `${provider}: ${PAYMENTS_DISPUTE_CANCEL_UNSUPPORTED_NOTE}` });
15909
+ }
15910
+ return out;
15911
+ }
15731
15912
  function sha12Of(scriptRef) {
15732
15913
  if (!scriptRef) return void 0;
15733
15914
  const m = /-([0-9a-f]{12})$/.exec(scriptRef);
@@ -18500,6 +18681,8 @@ function credentialEntry(row2, withHashes) {
18500
18681
  id: row2.id,
18501
18682
  ...withHashes && row2.password_hash ? { password_hash: row2.password_hash } : {},
18502
18683
  ...row2.email_verified ? { email_verified: true } : {},
18684
+ ...row2.created_at ? { created_at: row2.created_at } : {},
18685
+ ...row2.email_verified_at ? { email_verified_at: row2.email_verified_at } : {},
18503
18686
  ...identities.length > 0 ? { identities } : {}
18504
18687
  };
18505
18688
  }
@@ -18740,6 +18923,7 @@ function apiErr(prefix, res) {
18740
18923
  }
18741
18924
 
18742
18925
  // src/migrate/loaders/files.ts
18926
+ import { createHash as createHash3 } from "node:crypto";
18743
18927
  async function materialize(s) {
18744
18928
  if (s instanceof Uint8Array) return s;
18745
18929
  const chunks = [];
@@ -18965,7 +19149,12 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18965
19149
  ...orgId ? { org_id: orgId } : {},
18966
19150
  filename: obj.path.split("/").pop() ?? obj.path,
18967
19151
  content_type: contentType,
18968
- size_bytes: bytes.length
19152
+ size_bytes: bytes.length,
19153
+ // MX-1022-MIGRATE-FILES-SAFE: a migrated original never inherits the
19154
+ // bucket's expiry default (meant for NEW uploads), and the store verifies
19155
+ // its bytes against this checksum at complete.
19156
+ expiresInSeconds: null,
19157
+ checksum_sha256: createHash3("sha256").update(bytes).digest("hex")
18969
19158
  });
18970
19159
  if (created.status !== 201 && created.status !== 200) {
18971
19160
  const code = created.body.error?.code;
@@ -18974,12 +19163,13 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18974
19163
  }
18975
19164
  const objectId = created.body.data?.object_id;
18976
19165
  const uploadUrl = created.body.data?.upload_url;
19166
+ const uploadHeaders = created.body.data?.upload_headers;
18977
19167
  if (typeof objectId !== "string" || typeof uploadUrl !== "string") {
18978
19168
  return `files: upload-url for '${obj.path}' returned no object_id/upload_url`;
18979
19169
  }
18980
19170
  const putRes = await put(uploadUrl, {
18981
19171
  method: "PUT",
18982
- headers: { "content-type": contentType },
19172
+ headers: isStringRecord(uploadHeaders) ? uploadHeaders : { "content-type": contentType },
18983
19173
  body: bytes
18984
19174
  });
18985
19175
  if (!putRes.ok) {
@@ -18992,6 +19182,9 @@ async function uploadOne(deps, put, obj, map2, filesOwner, orgFromPrefix = false
18992
19182
  map2[obj.path] = objectId;
18993
19183
  return void 0;
18994
19184
  }
19185
+ function isStringRecord(v) {
19186
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
19187
+ }
18995
19188
 
18996
19189
  // src/migrate/loaders/payments.ts
18997
19190
  async function loadPaymentsCredits({ deps, adapter, mapping, state, save, spec, pkColumn, delta }) {
@@ -21157,7 +21350,7 @@ export default defineConfig({
21157
21350
  "hasFunctions": true,
21158
21351
  "byoKeys": [],
21159
21352
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Client Portal\" \u2014 the AGENCY / B2B portal: one WORKSPACE per client. The\n// client's people sign in as members of their own workspace, see only their\n// own requests and shared files, and get mail that comes from THEIR portal.\n// Declared end-to-end in ONE typed file.\n//\n// \u2022 auth + orgs \u2192 one workspace per client; client members are `viewer`s,\n// your staff are `admin`s of every client they serve; the\n// session acts in ONE active workspace (switchOrg)\n// \u2022 cms \u2192 `requests` (tickets), workspace-scoped (orgField): a\n// client opens and follows requests, only staff move them\n// \u2022 files \u2192 shared workspace files: clients upload and read, staff\n// (or the uploader) delete\n// \u2022 realtime \u2192 a live request board per client ('requests:{org}')\n// \u2022 notifications \u2192 mail sent AS the client's portal (a per-send sender:\n// \"Acme portal\" <acme@\u2026>), on new requests and status changes\n// \u2022 functions \u2192 the one notifier\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n methods: { emailPassword: true, magicLink: true },\n orgClaims: { enabled: true, activeOrg: true }, // one active client workspace per session\n session: { ttlMinutes: 15, refreshTtlDays: 30 },\n },\n\n orgs: {\n enabled: true,\n maxOrgsPerTenant: 500, // clients served by this project (up to 100,000)\n maxMembersPerOrg: 25, // client people + your staff, per client\n invitationTtlHours: 168,\n // Built-in roles only: `viewer` for the client's people, `admin` for\n // your staff on that client, `owner` for the account lead.\n },\n\n cms: {\n draftPublish: false,\n hooks: {\n // Who opened it comes from the verified session, never from the body.\n // (Your server key \u2014 `caller` is null \u2014 may set it, e.g. on an import.)\n request_requester: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'requester',\n expr: 'isNull(caller) ? item.requester : caller.endUserId',\n },\n request_opened_at: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'opened_at',\n expr: 'isNull(caller) ? coalesce(item.opened_at, now) : now',\n },\n // Every request starts as `open` (a server import may set another status).\n request_status_default: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'derive',\n field: 'status',\n expr: \"isNull(caller) ? coalesce(item.status, 'open') : 'open'\",\n },\n // A client never writes the notifier's mark or a resolved stamp.\n request_create_rules: {\n collection: 'requests',\n event: 'beforeCreate',\n kind: 'validate',\n expr: 'isNull(caller) || (isNull(item.notified_status) && isNull(item.resolved_at))',\n message: 'a new request is not resolved or notified yet',\n },\n // A client edits only the requests it opened; staff (the workspace's\n // admins and owners) edit any. `caller.org.role` is the ACTIVE\n // workspace role from the verified session. Deletes run no hooks: any\n // member can delete a request in its workspace (see the README).\n request_edit_own: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n \"isNull(caller) || caller.org.role == 'admin' || caller.org.role == 'owner'\"\n + ' || before.requester == caller.endUserId',\n message: 'only the requester or agency staff edit a request',\n },\n // Only staff move a request. Nobody signed in rewrites who opened it,\n // when, or the notifier's mark (a rewritten requester would be mailed).\n request_status_staff: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'validate',\n expr:\n 'isNull(caller) || (item.notified_status == before.notified_status'\n + ' && item.requester == before.requester && item.opened_at == before.opened_at'\n + \" && (item.status == before.status || caller.org.role == 'admin' || caller.org.role == 'owner'))\",\n message: 'only agency staff change the status of a request; requester and opened_at are fixed',\n },\n // Stamp when a request is resolved; reopening keeps the last stamp. A\n // signed-in caller can never set it by hand.\n request_resolved_at: {\n collection: 'requests',\n event: 'beforeUpdate',\n kind: 'derive',\n field: 'resolved_at',\n expr:\n \"item.status == 'resolved' && before.status != 'resolved' ? now\"\n + ' : (isNull(caller) ? item.resolved_at : before.resolved_at)',\n },\n },\n // A live board per client: each change publishes to org:<client>:requests,\n // which only sessions in that client's workspace can subscribe to.\n cdc: {\n requests_live: { collection: 'requests', channel: 'requests:{org}', events: ['created', 'updated'], payload: 'ids' },\n },\n },\n\n realtime: {},\n\n files: {\n // Shared workspace files (upload with `org: true`). Every member reads\n // them and may upload; deleting needs owner/admin or being the uploader.\n orgScope: { writeRoles: ['owner', 'admin'] },\n },\n\n // The portal's mail. `mock` needs no account. With a real provider, verify\n // the WHOLE fromEmail domain (not one address): every client's portal sends\n // from its own local part on that domain.\n notifications: {\n provider: 'mock',\n fromEmail: 'portal@clients.agency.example',\n fromName: 'Client portal',\n inboxEnabled: true,\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n requests: {\n singular: 'request',\n // WORKSPACE-SCOPED: a client sees its own requests only, and nobody\n // outside the workspace sees them. No writeRoles: every member may open\n // requests; the hooks above confine edits and status moves.\n orgField: 'workspace',\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n status: {\n type: 'string',\n indexSlot: 's2',\n validation: { enum: ['open', 'in_progress', 'waiting_on_client', 'resolved'] },\n },\n requester: { type: 'string', indexSlot: 's3' }, // the member who opened it (stamped)\n priority: { type: 'string', indexSlot: 's4', validation: { enum: ['low', 'normal', 'high', 'urgent'] } },\n opened_at: { type: 'datetime', indexSlot: 't1' },\n resolved_at: { type: 'datetime', indexSlot: 't2' },\n title: { type: 'string', required: true, validation: { max: 160 } },\n body: { type: 'text' },\n attachments: { type: 'json' }, // ids of shared workspace files\n // the last status the notifier mailed about (written by the function only)\n notified_status: { type: 'string' },\n },\n },\n },\n },\n\n functions: {\n // THE NOTIFIER. Woken by every committed create and update of a request.\n // New request \u2192 the client's staff (its admins and owners). Status change \u2192\n // the requester. Mail goes out as THAT client's portal (per-send sender).\n 'notify-request': {\n entry: './functions/notify-request.ts',\n trigger: { kind: 'cmsHook', collection: 'requests', event: 'beforeWrite' },\n scopes: ['cms:read', 'cms:write', 'orgs:read', 'notifications:send'],\n egressAllow: [],\n },\n },\n});\n",
21160
- "readme": "# Client Portal (support)\n\nThe **agency or B2B portal**: one workspace per client. The client's people sign in to a portal that shows\nonly their own requests and shared files. Your staff work across every client they serve. Every mail looks\nlike it comes from that client's own portal.\n\n```bash\nmkdir my-portal && cd my-portal\nvxil init --template client-portal\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # the collection, hooks, change feed and the notifier; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files and workspace change feeds (`{org}` channels) need `@vxil/cli` 0.26.0 and\n> `@vxil/config` 0.19.0 or later to push from code, and `@vxil/sdk` 0.26.0 or later in the app. `activeOrg`\n> needs `@vxil/sdk` 0.25.0 or later.\n>\n> **Plan note.** The notifier deploys on the Free plan when the project's workload is `staging` or\n> `development`. On a Free `production` project, upgrade to Developer, or push with `--skip-functions` (the\n> portal then works without mail).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One workspace per client | `features.orgs` + `auth.orgClaims: { enabled: true, activeOrg: true }` | a client's people are members of that client only; a staff member in several clients switches with `vx.auth.sessions.switchOrg` |\n| Client-scoped requests | `orgField: 'workspace'` on `requests` | a client reads and opens its own requests; another client's request is a `404` |\n| Staff move the status, clients do not | the `request_status_staff` hook on `caller.org.role` | a client opens requests and edits the text of its own (`request_edit_own`); only the workspace's `admin`s and `owner`s change a status. Deletes run no hooks, so any member can delete a request in its workspace \u2014 route deletes through a function if that matters to you |\n| The requester is who signed in | the `request_requester` derive hook | `requester` (and `opened_at`) come from the verified session and the clock, never from the request body, and no signed-in caller can change them later \u2014 the notifier mails whoever `requester` names |\n| Shared files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | every member of that client reads and uploads; only staff (or the uploader) delete |\n| A live board per client | `features.cms.cdc` with `channel: 'requests:{org}'` | each change goes to `org:<client>:requests`, which only that client's sessions can subscribe to |\n| Mail from the client's portal | `from_name` / `from_local_part` on each send, in `functions/notify-request.ts` | \"Acme portal\" <acme@clients.agency.example>, on your one verified domain |\n\n## Onboarding a client\n\nMemberships are server-only in this mode: an end-user session cannot create workspaces or add members\n(`403 server_only`). That fits a portal, because you onboard clients. From your backend, or with `vxil api`\nand a server key:\n\n```bash\n# the client's workspace; your account lead owns it\nvxil api POST /v1/orgs --data '{\"slug\":\"acme\",\"name\":\"Acme\",\"owner_user_id\":\"<account lead user id>\"}'\n# your staff on this client\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<staff user id>\",\"role\":\"admin\"}'\n# the client's people: an e-mail invitation as a viewer (vxil sends a best-effort mail; the token comes back once)\nvxil api POST /v1/orgs/org_\u2026/invitations --data '{\"email\":\"dana@acme.com\",\"role\":\"viewer\"}'\n```\n\nThe client accepts from your server, which checks who is accepting:\n`POST /v1/orgs/invitations/accept { token, user_id }` with a server key. Seat them only if the signed-in\nuser's verified e-mail is the invited one. A server-key accept does not compare the addresses for you.\n`orgs.maxMembersPerOrg` (25) caps people per client, staff included.\n\n## The portal\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// a client's people have one workspace; staff pick the client they are working on\nconst clients = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(clients[0]!.org_id);\n\n// a client opens a request (workspace, requester, status and opened_at are filled in for them)\nconst r = await vx.from('requests').create({ title: 'Update the pricing page', priority: 'high', body: '\u2026' });\n\n// the board: this client's open requests, newest first\nconst open = await vx.from('requests').query({ filter: { status: { $in: ['open', 'in_progress', 'waiting_on_client'] } }, sort: '-opened_at' });\n\n// staff move it (a client doing this gets 422 \"only agency staff change the status of a request; requester and opened_at are fixed\")\nawait vx.from('requests').patch(r.item_id, { status: 'in_progress' });\n```\n\nAttach a file: upload it into the workspace (`vx.files.createUploadUrl({ org: true, filename, content_type,\nsize_bytes })`, PUT the bytes, `vx.files.complete(object_id)`), then put its id in the request's `attachments`.\n`vx.files.list({ scope: 'org' })` lists everything the client has shared.\n\nFor the live board, open a realtime socket on `org:<client id>:requests` with the user's session and re-read\nthe request a frame names. A token for another client's channel is `403 org_channel_forbidden`.\n\n## The notifier\n\n`notify-request` wakes on every committed create and update of a request.\n\n- **A new request** is mailed to that client's staff (its `admin`s and `owner`s, minus the requester).\n- **A status change** is mailed to the requester.\n- **Every mail is sent as the client's portal.** `from_name` is the workspace name plus \"portal\".\n `from_local_part` is its slug. The domain is always your config `fromEmail` domain, so a send can never\n come from another domain. With a real provider, verify the **whole** domain, not one address, or the sends\n fail at the provider (`vx.notifications.senderDomain()` checks it).\n\nThe function marks each request with the status it mailed about (`notified_status`). Its own write wakes it\nonce more and finds nothing to do. A redelivered event is caught by the same mark and by a per-recipient\nidempotency key. Staff-only hooks let only your backend write the mark. If no mail was accepted, the mark\nstays unset, so the next write to that request tries again. Characters a sender name cannot carry\n(`( ) < > [ ] : ; @ \\ , \"`) are replaced by spaces.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The client filter is one more indexed condition in the same query.\n- **Per request created:** two function invocations (the mail, then the no-op after its own mark), one batch\n send of at most 25 items (`maxMembersPerOrg`), three reads and one write.\n- **Per status change:** two invocations, one send, three reads and one write. Text edits and other updates\n wake the function, which reads the row and stops.\n- **Functions** on a Free `staging` project run inside 10,000 invocations and 500,000 CPU-ms a day. A\n production project runs functions from Developer up.\n- **Realtime sockets per project:** 100 on Free, 1,000 on Developer, 5,000 on Team. **Files** count towards\n the project's storage quota (Free 10 GiB, Developer 25 GiB, Team 150 GiB).\n\n## What to learn from this\n\n- **A workspace is a customer boundary, not only a team.** One workspace per client gives every client a\n sealed portal from one project and one schema.\n- **Role rules that depend on the row live in hooks.** `caller.org.role` is the active workspace role, so\n \"clients edit their own, staff may move any\" is two expressions, enforced in the write.\n- **The sender belongs to the send.** One verified domain, one local part per client, no per-client mail\n setup.\n\n**Pairs with:** **helpdesk** (requester-owned tickets with an SLA sweep, without workspaces) and\n**team-saas** (workspaces your users create and manage themselves, with workspace billing).\n",
21353
+ "readme": "# Client Portal (support)\n\nThe **agency or B2B portal**: one workspace per client. The client's people sign in to a portal that shows\nonly their own requests and shared files. Your staff work across every client they serve. Every mail looks\nlike it comes from that client's own portal.\n\n```bash\nmkdir my-portal && cd my-portal\nvxil init --template client-portal\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # the collection, hooks, change feed and the notifier; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files and workspace change feeds (`{org}` channels) need `@vxil/cli` 0.26.0 and\n> `@vxil/config` 0.19.0 or later to push from code, and `@vxil/sdk` 0.26.0 or later in the app. `activeOrg`\n> needs `@vxil/sdk` 0.25.0 or later.\n>\n> **Plan note.** The notifier deploys on the Free plan when the project's workload is `staging` or\n> `development`. On a Free `production` project, upgrade to Developer, or push with `--skip-functions` (the\n> portal then works without mail).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One workspace per client | `features.orgs` + `auth.orgClaims: { enabled: true, activeOrg: true }` | a client's people are members of that client only; a staff member in several clients switches with `vx.auth.sessions.switchOrg` |\n| Client-scoped requests | `orgField: 'workspace'` on `requests` | a client reads and opens its own requests; another client's request is a `404` |\n| Staff move the status, clients do not | the `request_status_staff` hook on `caller.org.role` | a client opens requests and edits the text of its own (`request_edit_own`); only the workspace's `admin`s and `owner`s change a status. Deletes run no hooks, so any member can delete a request in its workspace \u2014 route deletes through a function if that matters to you |\n| The requester is who signed in | the `request_requester` derive hook | `requester` (and `opened_at`) come from the verified session and the clock, never from the request body, and no signed-in caller can change them later \u2014 the notifier mails whoever `requester` names |\n| Shared files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | every member of that client reads and uploads; only staff (or the uploader) delete |\n| A live board per client | `features.cms.cdc` with `channel: 'requests:{org}'` | each change goes to `org:<client>:requests`, which only that client's sessions can subscribe to |\n| Mail from the client's portal | `from_name` / `from_local_part` on each send, in `functions/notify-request.ts` | \"Acme portal\" <acme@clients.agency.example>, on your one verified domain |\n\n## Onboarding a client\n\nMemberships are server-only in this mode: an end-user session cannot create workspaces or add members\n(`403 server_only`). That fits a portal, because you onboard clients. From your backend, or with `vxil api`\nand a server key:\n\n```bash\n# the client's workspace; your account lead owns it\nvxil api POST /v1/orgs --data '{\"slug\":\"acme\",\"name\":\"Acme\",\"owner_user_id\":\"<account lead user id>\"}'\n# your staff on this client\nvxil api POST /v1/orgs/org_\u2026/members --data '{\"user_id\":\"<staff user id>\",\"role\":\"admin\"}'\n# the client's people: an e-mail invitation as a viewer (vxil sends a best-effort mail; the token comes back once)\nvxil api POST /v1/orgs/org_\u2026/invitations --data '{\"email\":\"dana@acme.com\",\"role\":\"viewer\"}'\n```\n\nThe client accepts from your server, which checks who is accepting:\n`POST /v1/orgs/invitations/accept { token, user_id }` with a server key. Seat them only if the signed-in\nuser's verified e-mail is the invited one. A server-key accept does not compare the addresses for you.\n`orgs.maxMembersPerOrg` (25) caps people per client, staff included.\n\n## The portal\n\n```ts\nimport { Vxil } from '@vxil/sdk';\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// a client's people have one workspace; staff pick the client they are working on\nconst clients = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(clients[0]!.org_id);\n\n// a client opens a request (workspace, requester, status and opened_at are filled in for them)\nconst r = await vx.from('requests').create({ title: 'Update the pricing page', priority: 'high', body: '\u2026' });\n\n// the board: this client's open requests, newest first\nconst open = await vx.from('requests').query({ filter: { status: { $in: ['open', 'in_progress', 'waiting_on_client'] } }, sort: '-opened_at' });\n\n// staff move it (a client doing this gets 422 \"only agency staff change the status of a request; requester and opened_at are fixed\")\nawait vx.from('requests').patch(r.item_id, { status: 'in_progress' });\n```\n\nAttach a file: upload it into the workspace (`vx.files.createUploadUrl({ org: true, filename, content_type,\nsize_bytes })`, PUT the bytes, `vx.files.complete(object_id)`), then put its id in the request's `attachments`.\n`vx.files.list({ scope: 'org' })` lists everything the client has shared.\n\nFor the live board, open a realtime socket on `org:<client id>:requests` with the user's session and re-read\nthe request a frame names. A token for another client's channel is `403 org_channel_forbidden`.\n\n## The notifier\n\n`notify-request` wakes on every committed create and update of a request.\n\n- **A new request** is mailed to that client's staff (its `admin`s and `owner`s, minus the requester).\n- **A status change** is mailed to the requester.\n- **Every mail is sent as the client's portal.** `from_name` is the workspace name plus \"portal\".\n `from_local_part` is its slug. The domain is always your config `fromEmail` domain, so a send can never\n come from another domain. With a real provider, verify the **whole** domain, not one address, or the sends\n fail at the provider (`vx.notifications.senderDomain()` checks it).\n\nThe function marks each request with the status it mailed about (`notified_status`). Its own write wakes it\nonce more and finds nothing to do. A redelivered event is caught by the same mark and by a per-recipient\nidempotency key. Staff-only hooks let only your backend write the mark. If no mail was accepted, the mark\nstays unset, so the next write to that request tries again. Characters a sender name cannot carry\n(`( ) < > [ ] : ; @ \\ , \"`) are replaced by spaces.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The client filter is one more indexed condition in the same query.\n- **Per request created:** two function invocations (the mail, then the no-op after its own mark), one batch\n send of at most 25 items (`maxMembersPerOrg`), three reads and one write.\n- **Per status change:** two invocations, one send, three reads and one write. Text edits and other updates\n wake the function, which reads the row and stops.\n- **Functions** on a Free `staging` project run inside 10,000 invocations and 500,000 CPU-ms a day. A\n production project runs functions from Developer up.\n- **Realtime sockets per project:** 500 on Free, 1,000 on Developer, 5,000 on Team. **Files** count towards\n the project's storage quota (Free 10 GiB, Developer 25 GiB, Team 150 GiB).\n\n## What to learn from this\n\n- **A workspace is a customer boundary, not only a team.** One workspace per client gives every client a\n sealed portal from one project and one schema.\n- **Role rules that depend on the row live in hooks.** `caller.org.role` is the active workspace role, so\n \"clients edit their own, staff may move any\" is two expressions, enforced in the write.\n- **The sender belongs to the send.** One verified domain, one local part per client, no per-client mail\n setup.\n\n**Pairs with:** **helpdesk** (requester-owned tickets with an SLA sweep, without workspaces) and\n**team-saas** (workspaces your users create and manage themselves, with workspace billing).\n",
21161
21354
  "functions": {
21162
21355
  "notify-request.ts": "// notify-request.ts \u2014 the portal's NOTIFIER (a vxil function).\n//\n// Trigger: cmsHook on `requests` (`beforeWrite`: every committed create and\n// update). The payload carries only { event, collection, item_id }, so the\n// function RE-FETCHES the row. It runs as your backend (a cms-hook delivery has\n// no user), so it reads any workspace's row and its members.\n//\n// new (not mailed) \u2192 mail the client's STAFF (the workspace's admins and\n// owners), except the requester\n// status changed \u2192 mail the REQUESTER (\"your request is now \u2026\")\n// anything else \u2192 nothing\n//\n// The mail goes out AS THAT CLIENT'S PORTAL: a per-send sender\n// from_name: \"Acme portal\", from_local_part: \"acme\" \u2192 \"Acme portal\" <acme@<your fromEmail domain>>\n// The domain is always your config `fromEmail` domain (a send can never come\n// from another domain), so verify the WHOLE domain at your mail provider.\n//\n// `notified_status` on the row is the dedupe mark: after mailing, the function\n// writes the status it mailed about. That write wakes the function once more,\n// and it finds nothing to do. Delivery is at-least-once, so every send also\n// carries an idempotency key derived from the delivery's own key.\n//\n// Bounds per delivery: 3 reads, 1 batch send of at most orgs.maxMembersPerOrg\n// (25) items, 1 write.\n\nimport type { CmsHookFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = CmsHookFunctionEnvelope;\ninterface Request_ {\n workspace?: string;\n status?: string;\n requester?: string;\n title?: string;\n notified_status?: string;\n}\nconst STATUS_WORDS: Record<string, string> = {\n open: 'open',\n in_progress: 'in progress',\n waiting_on_client: 'waiting on you',\n resolved: 'resolved',\n};\n/** The portal link in the mail. Replace with your app's page. */\nconst PORTAL_URL = 'https://portal.example.com/requests/';\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const { cms, orgs, notifications } = env.scoped_jwts ?? {};\n const itemId = env.payload?.item_id;\n if (env.payload?.collection !== 'requests' || !cms || !orgs || !notifications || !itemId) {\n return Response.json({ skipped: true });\n }\n const auth = (t: string) => ({ authorization: `Bearer ${t}`, 'content-type': 'application/json' });\n\n // 1) the row as it is NOW (a later delivery may already have moved it)\n const res = await fetch(`${base}/v1/cms/items/requests/${encodeURIComponent(itemId)}`, { headers: auth(cms) });\n if (!res.ok) return Response.json({ skipped: true, reason: `fetch ${res.status}` });\n const row = ((await res.json()) as { data?: { data?: Request_ } }).data?.data ?? {};\n const status = row.status ?? 'open';\n if (!row.workspace) return Response.json({ skipped: true, reason: 'no workspace' });\n // Already mailed about this status (the write below, or a redelivery).\n if (row.notified_status === status) return Response.json({ skipped: true, reason: 'already notified' });\n // Never mailed yet (no mark) = the NEW-REQUEST mail, whichever delivery\n // gets here first (the create, or an early edit if that mail failed).\n const isNew = !row.notified_status;\n\n // 2) the client workspace: its name and slug become this send's sender\n const orgRes = await fetch(`${base}/v1/orgs/${encodeURIComponent(row.workspace)}`, { headers: auth(orgs) });\n if (!orgRes.ok) return Response.json({ skipped: true, reason: `org ${orgRes.status}` });\n const org = ((await orgRes.json()) as { data?: { name?: string; slug?: string } }).data ?? {};\n const clientName = (org.name ?? 'Client').trim() || 'Client';\n // A sender name refuses control/bidi characters and ( ) < > [ ] : ; @ \\ , \"\n // (422 per item), so the name is cleaned to what the provider header allows.\n const senderName = `${clientName} portal`.replace(/[\\p{Cc}\\u2028\\u2029\\u202A-\\u202E\\u2066-\\u2069()<>[\\]:;@\\\\,\"]/gu, ' ')\n .replace(/\\s+/g, ' ').trim().slice(0, 64);\n const sender = {\n ...(senderName ? { from_name: senderName } : {}),\n // org slugs are a-z, 0-9 and '-', a subset of the local-part alphabet\n ...(org.slug ? { from_local_part: org.slug.slice(0, 40) } : {}),\n };\n\n // 3) who hears about it\n let recipients: string[] = [];\n let subject: string;\n let paragraph: string;\n if (isNew) {\n const memRes = await fetch(`${base}/v1/orgs/${encodeURIComponent(row.workspace)}/members`, { headers: auth(orgs) });\n const members = memRes.ok\n ? (((await memRes.json()) as { data?: { members?: Array<{ user_id: string; role: string }> } }).data?.members ?? [])\n : [];\n recipients = members\n .filter((m) => (m.role === 'admin' || m.role === 'owner') && m.user_id !== row.requester)\n .map((m) => m.user_id);\n subject = `New request from ${clientName}: ${row.title ?? itemId}`;\n paragraph = `${clientName} opened a request${row.title ? ` \"${row.title}\"` : ''}. Open the portal to pick it up.`;\n } else {\n if (row.requester) recipients = [row.requester];\n subject = `Your request is ${STATUS_WORDS[status] ?? status}`;\n paragraph = `\"${row.title ?? itemId}\" is now ${STATUS_WORDS[status] ?? status}.`;\n }\n\n let accepted = 0;\n if (recipients.length) {\n const send = await fetch(`${base}/v1/notifications/send/batch`, {\n method: 'POST',\n headers: auth(notifications),\n body: JSON.stringify({\n items: recipients.slice(0, 100).map((user_id) => ({\n user_id,\n template: 'transactional',\n channel: 'both', // e-mail + the in-app inbox\n data: { subject, paragraph, cta_label: 'Open the request', cta_url: `${PORTAL_URL}${itemId}` },\n ...sender,\n // the new-request mail is keyed on the request (two racing deliveries\n // send it once); a status mail on this delivery\n idempotency_key: (isNew ? `new:${itemId}:${user_id}` : `${env.idempotency_key}:${status}:${user_id}`).slice(0, 255),\n })),\n }),\n });\n // A batch answers 202 even when items fail (each item is its own result),\n // so success is the accepted count, not the status.\n accepted = send.ok ? (((await send.json()) as { data?: { accepted?: number } }).data?.accepted ?? 0) : 0;\n // Nothing went out: leave the mark unset, so the next write to this\n // request tries again. (This answer is recorded, not re-delivered.)\n if (accepted === 0) return Response.json({ notified: 0, status: send.status }, { status: 502 });\n }\n\n // 4) the dedupe mark (a server write: the staff-only hooks let it through)\n await fetch(`${base}/v1/cms/items/requests/${encodeURIComponent(itemId)}`, {\n method: 'PATCH',\n headers: auth(cms),\n body: JSON.stringify({ data: { notified_status: status } }),\n });\n return Response.json({ notified: accepted, recipients: recipients.length, status });\n },\n};\n"
21163
21356
  }
@@ -21229,7 +21422,7 @@ export default defineConfig({
21229
21422
  "hasFunctions": true,
21230
21423
  "byoKeys": [],
21231
21424
  "configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Team SaaS\" \u2014 the canonical B2B team product: every customer gets WORKSPACES,\n// each workspace shares its own folders, docs and files, sees its own live\n// changes, and carries its own plan. Declared end-to-end in ONE typed file.\n//\n// \u2022 auth \u2192 the session acts in ONE active workspace (orgClaims.activeOrg);\n// the app switches with vx.auth.sessions.switchOrg(orgId)\n// \u2022 orgs \u2192 workspaces + memberships; roles owner > admin > editor > viewer\n// (`editor` is a CUSTOM role declared below)\n// \u2022 cms \u2192 `folders` (a parent self-relation) and `docs`, both\n// WORKSPACE-SCOPED (orgField): members share rows, nobody else\n// sees them; one change feed per workspace ('docs:{org}')\n// \u2022 files \u2192 workspace-owned files (upload with `org: true`) that survive\n// the uploader's erasure\n// \u2022 payments \u2192 a payments INTEGRATION (your own provider account; vxil never\n// in the flow of funds) with the WORKSPACE as the billing subject:\n// a `free` tier every new workspace gets, and a paid `team` tier\n// \u2022 functions \u2192 the membership changes (server-only in this mode) and the\n// team checkout, each behind a role check of the caller\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n auth: {\n methods: { emailPassword: true, magicLink: true },\n // ACTIVE-ORG MODE. The session carries ONE chosen workspace and keeps it:\n // the first sign-in picks the most recent membership, switchOrg moves it,\n // a refresh never falls back to another workspace. The workspace role\n // gates ONLY workspace-scoped collections \u2014 owning a workspace never\n // opens an unscoped one. Turn it on before end users sign in.\n orgClaims: { enabled: true, activeOrg: true },\n // A membership change reaches live sessions in about 60 s; if that notice\n // cannot be delivered, the access-token TTL is the bound \u2014 keep it short.\n session: { ttlMinutes: 15, refreshTtlDays: 30 },\n },\n\n orgs: {\n enabled: true,\n // One project serves many customer workspaces. The org cap is a count of\n // workspaces in THIS project (schema max 100,000); raise it as you grow.\n maxOrgsPerTenant: 1000,\n // The hard seat ceiling per workspace; the PLAN's seat quota (payments\n // below) is the commercial one the membership function enforces.\n maxMembersPerOrg: 50,\n invitationTtlHours: 72,\n // The built-ins are owner > admin > member > viewer. `editor` is ours: a\n // plain role key the cms gates below name. The permission strings are\n // yours too \u2014 vxil only answers whether a member holds one.\n roles: [\n { role_key: 'editor', name: 'Editor', permissions: ['docs.write', 'folders.write'], rank: 2 },\n ],\n },\n\n cms: {\n // Docs are live the moment they are written \u2014 a workspace tool, not a CMS.\n draftPublish: false,\n // ONE CHANGE FEED PER WORKSPACE. `{org}` resolves per row from the\n // collection's orgField: a change to a doc of workspace W publishes to\n // `org:W:docs`, and only a session whose ACTIVE workspace is W can mint a\n // token for that channel. Frames carry ids only \u2014 the client re-reads the\n // row, and the workspace rule applies there.\n cdc: {\n docs_live: { collection: 'docs', channel: 'docs:{org}', events: ['created', 'updated', 'deleted'], payload: 'ids' },\n folders_live: { collection: 'folders', channel: 'folders:{org}', events: ['created', 'updated', 'deleted'], payload: 'ids' },\n },\n },\n\n realtime: {}, // the sockets the change feeds above publish to\n\n files: {\n // WORKSPACE FILES. Any member of the active workspace reads a file the\n // workspace owns; deleting it (and its TTL, OCR, shared links) needs one\n // of these roles or being the uploader. The workspace `owner` always passes.\n orgScope: { writeRoles: ['owner', 'admin', 'editor'] },\n },\n\n // A payments INTEGRATION: the tenant's own Stripe/Paddle/PayPal account;\n // vxil records what the provider reports and is never in the flow of funds.\n // `mock` runs everything with no account. Go live with your provider block,\n // e.g. paddle: { apiKeyRef: 'paddle_api_key', webhookSecretRef: 'paddle_webhook' }.\n payments: {\n enabled: true,\n provider: 'mock',\n defaults: { currency: 'usd' },\n ledger: {\n productMap: {},\n // The workspace's plan. The higher `rank` wins when a workspace holds\n // both: `free` is granted once when the workspace is created (by the\n // membership function), `team` comes from the provider subscription.\n tierMap: {\n free: { entitlements: ['workspace'], quotas: { seats: 3 }, rank: 0 },\n team: { entitlements: ['workspace', 'team'], quotas: { seats: 50 }, rank: 10 },\n },\n // your provider's price id \u2192 tier (replace with the real one)\n priceMap: { pri_team_monthly: 'team' },\n },\n },\n\n functions: { enabled: true },\n },\n\n // \u2500\u2500 Schema-as-code (\u22648 index slots per collection: s1\u2013s4/n1\u2013n2/t1\u2013t2) \u2500\u2500\u2500\u2500\u2500\u2500\n cms: {\n collections: {\n // A folder needs no access control of its own: it is a row of the\n // workspace, so every member sees the whole tree and `writeRoles` decides\n // who may change it. Nest with `parent` (null = a root folder).\n folders: {\n singular: 'folder',\n orgField: 'workspace', // stamped with the session's active workspace on create\n writeRoles: ['editor', 'admin'], // matched against the ACTIVE-workspace role; `owner` always passes\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n // `restrict`: a folder that still has sub-folders cannot be deleted \u2014\n // delete a tree bottom-up (guide ch. 4, \"Folders in a workspace\").\n parent: { type: 'relation', relationTo: 'folders', onDelete: 'restrict', indexSlot: 's2' },\n // \"/<rootId>/<childId>/\u2026\" \u2014 lists a whole subtree in one\n // `{ path: { $startsWith } }` query. Nothing on the server sets it or\n // stops a cycle: your app (or a function) writes `path` and, on a\n // move, refuses a `parent` whose path starts with the folder's own.\n path: { type: 'string', indexSlot: 's3' },\n name: { type: 'string', required: true, indexSlot: 's4' },\n position: { type: 'float', indexSlot: 'n1' },\n created_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n\n docs: {\n singular: 'doc',\n orgField: 'workspace',\n writeRoles: ['editor', 'admin'], // viewers read, editors and admins write\n fields: {\n workspace: { type: 'string', indexSlot: 's1' },\n // a doc can only be filed in a folder of the SAME workspace\n // (422 relation_not_in_org otherwise), and a folder holding docs is\n // not deletable until they move\n folder: { type: 'relation', relationTo: 'folders', onDelete: 'restrict', indexSlot: 's2' },\n title: { type: 'string', required: true, indexSlot: 's3' },\n author: { type: 'string', indexSlot: 's4' }, // the member who wrote it (display only)\n word_count: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n body: { type: 'text' },\n attachments: { type: 'json' }, // ids of workspace files (vx.files.createUploadUrl({ org: true, \u2026 }))\n },\n },\n },\n },\n\n // \u2500\u2500 The steps that are not config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n functions: {\n // MEMBERSHIP CHANGES. In active-org mode an end-user session cannot write\n // orgs at all (403 server_only), because a membership IS the data boundary.\n // The app calls this function AS the signed-in user; it checks the caller's\n // role with the caller's own token (the verified identity, never the\n // payload), then writes with `server_jwts`, tokens not bound to the user.\n // Actions: create \xB7 invite \xB7 accept \xB7 set_role \xB7 remove.\n 'workspace-members': {\n entry: './functions/workspace-members.ts',\n trigger: { kind: 'http', serverScopes: ['orgs:read', 'orgs:write', 'payments:read', 'payments:write'] },\n scopes: ['orgs:read', 'orgs:write', 'payments:read', 'payments:write'],\n egressAllow: [], // nothing external; it only talks back to your own backend\n signature: {\n // the values are TypeScript types, emitted verbatim by `vxil gen`\n input: {\n action: \"'create' | 'invite' | 'accept' | 'set_role' | 'remove'\",\n org_id: 'string | undefined',\n slug: 'string | undefined',\n name: 'string | undefined',\n role: 'string | undefined',\n user_id: 'string | undefined',\n token: 'string | undefined',\n },\n },\n },\n\n // THE TEAM CHECKOUT. A workspace admin opens a provider checkout whose\n // SUBJECT is the workspace (`org_id`), so the plan belongs to the workspace,\n // not to whoever paid, and survives an ownership hand-over.\n 'workspace-billing': {\n entry: './functions/workspace-billing.ts',\n trigger: { kind: 'http', serverScopes: ['payments:write'] },\n scopes: ['orgs:read', 'payments:write'],\n egressAllow: [],\n signature: {\n input: { org_id: 'string', success_url: 'string | undefined', cancel_url: 'string | undefined' },\n output: '{ url: string }',\n },\n },\n },\n});\n",
21232
- "readme": "# Team SaaS (saas)\n\nThe canonical **B2B team product**. Each customer gets one or more **workspaces**. A workspace shares its\nown folders, docs and files, sees its own live changes, and carries its own plan. A signed-in user acts in\n**one active workspace** at a time and switches between them like in any team tool.\n\nEverything a \"team SaaS\" usually builds by hand around its tables is declared here instead: the workspace\nboundary on every read and write, roles inside a workspace, a change feed per workspace, workspace-owned\nfiles and a plan per workspace. The two steps that need product rules (who may invite, change roles or buy\nthe plan) are two small functions.\n\n```bash\nmkdir my-team-app && cd my-team-app\nvxil init --template team-saas\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections, roles, plans, change feeds, both functions; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files, workspace change feeds (`{org}` channels) and the client half of this\n> blueprint need `@vxil/cli` 0.26.0 and `@vxil/config` 0.19.0 or later (older versions refuse the `{org}`\n> channel when they validate locally), and the app needs `@vxil/sdk` 0.26.0 or later. `activeOrg` itself\n> needs `@vxil/sdk` 0.25.0 or later in the app: older SDKs keep getting `401 session_stale` after a membership\n> change until the access token expires.\n>\n> **Plan note.** The two functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload` step above). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or push with\n> `--skip-functions` (the workspace data model works without them, but membership changes then need your own\n> server).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One active workspace per session | `features.auth.orgClaims: { enabled: true, activeOrg: true }` | the session carries one chosen workspace; `vx.auth.sessions.switchOrg(orgId)` moves it; a refresh never silently falls back to another workspace |\n| Roles inside a workspace | `features.orgs.roles` (`editor`) + the built-in `owner`, `admin`, `viewer` | `vxil push` creates `editor`; the workspace role gates only workspace collections, so owning a workspace never opens anything else |\n| Workspace-scoped rows | `orgField: 'workspace'` on `folders` and `docs` | members share the rows; another workspace's row is a `404`; a create is stamped with the active workspace |\n| Who may write | `writeRoles: ['editor', 'admin']` | viewers read, editors and admins write; the workspace `owner` always passes |\n| Folders and sub-folders | `folders.parent` (a relation to `folders`) + `path` | list a folder with `{ parent: id }`, the root with `{ parent: null }`, a subtree with `{ path: { $startsWith } }`. The server does not maintain `path` or prevent cycles: your app writes `path`, and on a move refuses a new `parent` whose `path` starts with the moved folder's own |\n| A change feed per workspace | `features.cms.cdc` with `channel: 'docs:{org}'` | each change goes to `org:<workspace id>:docs`, and only a session whose active workspace that is can subscribe |\n| Workspace files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | any member reads them; they stay when the uploader's account is erased; deleting the workspace deletes them |\n| The workspace pays | `features.payments.ledger.tierMap` (`free`, `team`) + `org_id` on the checkout | the plan's subject is `org:<workspace id>`: every member reads it, and an ownership hand-over changes nothing |\n| Membership changes | `functions.workspace-members` with `serverScopes` | the caller's role is checked with the caller's own token, then the write is made as your backend |\n\n## The workspace flow in the app\n\nBuild the client with the **whole session pair**, so the SDK can rotate it on `switchOrg` and on\n`session_stale`:\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// `pair` is the { token, refresh_token, \u2026 } session from sign-in; `save` persists it (a cookie, storage)\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// 1) a brand-new user creates their first workspace (they become its owner, on the free plan)\nconst ws = await vx.fn['workspace-members']({ action: 'create', slug: 'acme', name: 'Acme' });\nawait vx.auth.sessions.switchOrg(ws.org_id);\n\n// 2) the workspace switcher: the user's workspaces, each with their role\nconst mine = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(mine[0]!.org_id);\nvx.auth.session.org; // \u2192 { id, role, perms } of the active workspace\n\n// 3) everything below acts in the active workspace, with no workspace id in the call\nconst root = await vx.from('folders').create({ name: 'Specs' });\nawait vx.from('docs').create({ title: 'Kickoff', folder: root.item_id, body: '\u2026' });\nconst inRoot = await vx.from('docs').query({ filter: { folder: root.item_id } });\n```\n\nThe `workspace` field is stamped for you. Naming another workspace is `403 other_org`. A session with no\nactive workspace gets `403 org_required` (send the user to the switcher). A doc filed in another\nworkspace's folder is `422 relation_not_in_org`.\n\n**Removed from a workspace?** Within about 60 seconds the user's next request answers `401 session_stale`. The\nSDK refreshes the pair once. The new pair carries the new role, or no workspace at all after a removal (the\nrefresh answer's `org` is `null`: show the switcher). Then it retries the request. An answer with\n`org_unavailable: true` means the workspace service could not be reached: keep the workspace and try again.\nDo not switch on it.\n\n## Inviting a teammate\n\nIn this mode an end-user session cannot write memberships at all (`403 server_only`): a membership decides\nwhich workspace's rows a session reads, so it is a server decision. The app calls the `workspace-members`\nfunction instead. The function checks the caller's role and then acts as your backend:\n\n```ts\n// an admin or owner of the active workspace\nconst { invite_token } = await vx.fn['workspace-members']({ action: 'invite', org_id: ws.org_id, role: 'editor' });\n// build your own accept link and deliver it, e.g. https://app.example.com/join?token=\u2026\n\n// the invitee, signed in, on that page\nconst joined = await vx.fn['workspace-members']({ action: 'accept', token });\nawait vx.auth.sessions.switchOrg(joined.org_id);\n```\n\n- **Link invites.** The function issues invitations without an e-mail address, so vxil sends nothing and\n anyone signed in who holds the link can take the seat once. The token is returned once and expires after\n `orgs.invitationTtlHours` (72 here). To bind an invitation to one address instead, create it with an `email`\n from your server and check the accepting user's verified address in the function before you accept: a\n server-key accept does not compare the addresses for you.\n- **Seats come from the plan.** An invite is refused with `402 seats_exhausted` when members plus pending\n invitations reach the workspace's `seats` quota (3 on `free`, 50 on `team`). `orgs.maxMembersPerOrg`\n (50) is the hard ceiling behind it.\n- **Roles.** An invite carries `admin`, `editor` or `viewer`. `set_role` changes a member's role (admin or\n owner). Granting or taking `owner` needs an owner, and a workspace always keeps one owner (`409`). `remove`\n removes a member, or lets a user leave (`user_id` = themselves).\n- **Workspaces per user.** `create` refuses a sixth owned workspace (`MAX_OWNED_WORKSPACES` in the function).\n `orgs.maxOrgsPerTenant` (1,000 here, up to 100,000) is shared by every user of the project.\n\n## Live changes in the active workspace\n\n`docs_live` publishes every created, updated or deleted doc of workspace W to `org:W:docs`, with ids only.\nMint a connect token for that channel with the user's session, subscribe, and re-read the row a frame names\n(the workspace rule applies to the re-read):\n\n```ts\nconst channel = `org:${vx.auth.session.org!.id}:docs`;\n// open the socket with vx.realtime.tokenProvider({ user_id }) as the token provider (guide ch. 6, realtime)\n```\n\nA token for another workspace's channel is `403 org_channel_forbidden`. After `switchOrg`, mint a token for\nthe new workspace's channel. A frame is a hint, not the data, so a client that missed one loses nothing it\ncannot re-read.\n\n## Workspace files\n\n```ts\nconst up = await vx.files.createUploadUrl({ org: true, filename: 'brief.pdf', content_type: 'application/pdf', size_bytes: 48_211 });\nawait fetch(up.upload_url, { method: 'PUT', headers: { 'content-type': 'application/pdf' }, body: bytes });\nawait vx.files.complete(up.object_id);\nconst shared = await vx.files.list({ scope: 'org' }); // the active workspace's files\n```\n\nAny member of the workspace can upload into it, read its files and mint download URLs. Deleting a file, its\nTTL, OCR and shared links need `owner`, `admin` or `editor` (`files.orgScope.writeRoles`), or being the\nuploader. Outside its workspace a workspace file is a `404`, for the uploader too. Erasing the uploader keeps\nthe file; deleting the workspace deletes its files in the background.\n\n## Billing the workspace\n\nThe plan is a **payments integration**: your own Stripe, Paddle or PayPal account. vxil records what the\nprovider reports and is never in the flow of funds. This blueprint runs on `mock` until you add your\nprovider block and replace `pri_team_monthly` in `ledger.priceMap` with your real price id.\n\n- **`free`.** `create` grants it once to every new workspace (a manual, open-ended plan whose subject is the\n workspace).\n- **`team`.** A workspace admin opens the checkout through the `workspace-billing` function:\n `const { url } = await vx.fn['workspace-billing']({ org_id })`, then `location.assign(url)`. The checkout's\n subject is the workspace, so the subscription folds onto `org:<workspace id>`. It outranks `free` (`rank`\n 10 against 0).\n- **Reading it.** Any member reads the active workspace's plan:\n `await vx.payments.hasEntitlement({ subject: 'org' }, 'team')`. Your server reads any workspace's plan with\n `{ org_id }`.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The workspace is one more indexed condition in the same query: no added round\n trip, no added read.\n- **A membership change** is one function invocation (at most 4 reads and 2 writes against your own backend,\n no fan-out). The billing function is one read and one write. On a Free `staging` project, functions run\n inside 10,000 invocations and 500,000 CPU-ms a day. A production project runs functions from Developer up.\n- **Change feeds** publish one frame per changed row, ids only. Realtime sockets per project follow your\n plan: 100 on Free, 1,000 on Developer, 5,000 on Team.\n- **Files** count towards the project's storage quota like every file (Free 10 GiB, Developer 25 GiB, Team\n 150 GiB).\n- **Payments.** vxil adds no fee to the money flow. Your provider charges its own fees.\n\n## What to learn from this\n\n- **The workspace boundary is config, not code.** `orgField` puts the workspace predicate on every end-user\n read, write, count, join, cascade and change feed. Your server key is never affected.\n- **A membership is a data boundary, so it changes on the server.** The workspaceGuard pattern is three\n lines: who (`env.end_user`), may they (`/check` with their own token), then act (`server_jwts`).\n- **The workspace is the customer.** It holds the plan, the files and the rows, so people can come and go\n without anything being re-keyed.\n\n**Pairs with:** **team-workspace** (orgs with SSO, field-level security and one action button, without the\nactive workspace) and **ai-knowledge-base** (this workspace model plus search and grounded answers).\n",
21425
+ "readme": "# Team SaaS (saas)\n\nThe canonical **B2B team product**. Each customer gets one or more **workspaces**. A workspace shares its\nown folders, docs and files, sees its own live changes, and carries its own plan. A signed-in user acts in\n**one active workspace** at a time and switches between them like in any team tool.\n\nEverything a \"team SaaS\" usually builds by hand around its tables is declared here instead: the workspace\nboundary on every read and write, roles inside a workspace, a change feed per workspace, workspace-owned\nfiles and a plan per workspace. The two steps that need product rules (who may invite, change roles or buy\nthe plan) are two small functions.\n\n```bash\nmkdir my-team-app && cd my-team-app\nvxil init --template team-saas\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # collections, roles, plans, change feeds, both functions; writes vxil.types.ts\n```\n\n> **Versions.** Workspace files, workspace change feeds (`{org}` channels) and the client half of this\n> blueprint need `@vxil/cli` 0.26.0 and `@vxil/config` 0.19.0 or later (older versions refuse the `{org}`\n> channel when they validate locally), and the app needs `@vxil/sdk` 0.26.0 or later. `activeOrg` itself\n> needs `@vxil/sdk` 0.25.0 or later in the app: older SDKs keep getting `401 session_stale` after a membership\n> change until the access token expires.\n>\n> **Plan note.** The two functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload` step above). On a Free `production` project, `vxil push` stops\n> before it writes anything and names the ways out: change the workload, upgrade to Developer, or push with\n> `--skip-functions` (the workspace data model works without them, but membership changes then need your own\n> server).\n\n## What this blueprint teaches\n\n| What | Where it is declared | What it buys you |\n|---|---|---|\n| One active workspace per session | `features.auth.orgClaims: { enabled: true, activeOrg: true }` | the session carries one chosen workspace; `vx.auth.sessions.switchOrg(orgId)` moves it; a refresh never silently falls back to another workspace |\n| Roles inside a workspace | `features.orgs.roles` (`editor`) + the built-in `owner`, `admin`, `viewer` | `vxil push` creates `editor`; the workspace role gates only workspace collections, so owning a workspace never opens anything else |\n| Workspace-scoped rows | `orgField: 'workspace'` on `folders` and `docs` | members share the rows; another workspace's row is a `404`; a create is stamped with the active workspace |\n| Who may write | `writeRoles: ['editor', 'admin']` | viewers read, editors and admins write; the workspace `owner` always passes |\n| Folders and sub-folders | `folders.parent` (a relation to `folders`) + `path` | list a folder with `{ parent: id }`, the root with `{ parent: null }`, a subtree with `{ path: { $startsWith } }`. The server does not maintain `path` or prevent cycles: your app writes `path`, and on a move refuses a new `parent` whose `path` starts with the moved folder's own |\n| A change feed per workspace | `features.cms.cdc` with `channel: 'docs:{org}'` | each change goes to `org:<workspace id>:docs`, and only a session whose active workspace that is can subscribe |\n| Workspace files | `features.files.orgScope` + `vx.files.createUploadUrl({ org: true, \u2026 })` | any member reads them; they stay when the uploader's account is erased; deleting the workspace deletes them |\n| The workspace pays | `features.payments.ledger.tierMap` (`free`, `team`) + `org_id` on the checkout | the plan's subject is `org:<workspace id>`: every member reads it, and an ownership hand-over changes nothing |\n| Membership changes | `functions.workspace-members` with `serverScopes` | the caller's role is checked with the caller's own token, then the write is made as your backend |\n\n## The workspace flow in the app\n\nBuild the client with the **whole session pair**, so the SDK can rotate it on `switchOrg` and on\n`session_stale`:\n\n```ts\nimport { Vxil } from '@vxil/sdk';\n\n// `pair` is the { token, refresh_token, \u2026 } session from sign-in; `save` persists it (a cookie, storage)\nconst vx = new Vxil({ apiKey: PUBLIC_KEY, endUserSession: pair, onSessionChange: (s) => save(s) });\n\n// 1) a brand-new user creates their first workspace (they become its owner, on the free plan)\nconst ws = await vx.fn['workspace-members']({ action: 'create', slug: 'acme', name: 'Acme' });\nawait vx.auth.sessions.switchOrg(ws.org_id);\n\n// 2) the workspace switcher: the user's workspaces, each with their role\nconst mine = await vx.orgs.list();\nawait vx.auth.sessions.switchOrg(mine[0]!.org_id);\nvx.auth.session.org; // \u2192 { id, role, perms } of the active workspace\n\n// 3) everything below acts in the active workspace, with no workspace id in the call\nconst root = await vx.from('folders').create({ name: 'Specs' });\nawait vx.from('docs').create({ title: 'Kickoff', folder: root.item_id, body: '\u2026' });\nconst inRoot = await vx.from('docs').query({ filter: { folder: root.item_id } });\n```\n\nThe `workspace` field is stamped for you. Naming another workspace is `403 other_org`. A session with no\nactive workspace gets `403 org_required` (send the user to the switcher). A doc filed in another\nworkspace's folder is `422 relation_not_in_org`.\n\n**Removed from a workspace?** Within about 60 seconds the user's next request answers `401 session_stale`. The\nSDK refreshes the pair once. The new pair carries the new role, or no workspace at all after a removal (the\nrefresh answer's `org` is `null`: show the switcher). Then it retries the request. An answer with\n`org_unavailable: true` means the workspace service could not be reached: keep the workspace and try again.\nDo not switch on it.\n\n## Inviting a teammate\n\nIn this mode an end-user session cannot write memberships at all (`403 server_only`): a membership decides\nwhich workspace's rows a session reads, so it is a server decision. The app calls the `workspace-members`\nfunction instead. The function checks the caller's role and then acts as your backend:\n\n```ts\n// an admin or owner of the active workspace\nconst { invite_token } = await vx.fn['workspace-members']({ action: 'invite', org_id: ws.org_id, role: 'editor' });\n// build your own accept link and deliver it, e.g. https://app.example.com/join?token=\u2026\n\n// the invitee, signed in, on that page\nconst joined = await vx.fn['workspace-members']({ action: 'accept', token });\nawait vx.auth.sessions.switchOrg(joined.org_id);\n```\n\n- **Link invites.** The function issues invitations without an e-mail address, so vxil sends nothing and\n anyone signed in who holds the link can take the seat once. The token is returned once and expires after\n `orgs.invitationTtlHours` (72 here). To bind an invitation to one address instead, create it with an `email`\n from your server and check the accepting user's verified address in the function before you accept: a\n server-key accept does not compare the addresses for you.\n- **Seats come from the plan.** An invite is refused with `402 seats_exhausted` when members plus pending\n invitations reach the workspace's `seats` quota (3 on `free`, 50 on `team`). `orgs.maxMembersPerOrg`\n (50) is the hard ceiling behind it.\n- **Roles.** An invite carries `admin`, `editor` or `viewer`. `set_role` changes a member's role (admin or\n owner). Granting or taking `owner` needs an owner, and a workspace always keeps one owner (`409`). `remove`\n removes a member, or lets a user leave (`user_id` = themselves).\n- **Workspaces per user.** `create` refuses a sixth owned workspace (`MAX_OWNED_WORKSPACES` in the function).\n `orgs.maxOrgsPerTenant` (1,000 here, up to 100,000) is shared by every user of the project.\n\n## Live changes in the active workspace\n\n`docs_live` publishes every created, updated or deleted doc of workspace W to `org:W:docs`, with ids only.\nMint a connect token for that channel with the user's session, subscribe, and re-read the row a frame names\n(the workspace rule applies to the re-read):\n\n```ts\nconst channel = `org:${vx.auth.session.org!.id}:docs`;\n// open the socket with vx.realtime.tokenProvider({ user_id }) as the token provider (guide ch. 6, realtime)\n```\n\nA token for another workspace's channel is `403 org_channel_forbidden`. After `switchOrg`, mint a token for\nthe new workspace's channel. A frame is a hint, not the data, so a client that missed one loses nothing it\ncannot re-read.\n\n## Workspace files\n\n```ts\nconst up = await vx.files.createUploadUrl({ org: true, filename: 'brief.pdf', content_type: 'application/pdf', size_bytes: 48_211 });\nawait fetch(up.upload_url, { method: 'PUT', headers: { 'content-type': 'application/pdf' }, body: bytes });\nawait vx.files.complete(up.object_id);\nconst shared = await vx.files.list({ scope: 'org' }); // the active workspace's files\n```\n\nAny member of the workspace can upload into it, read its files and mint download URLs. Deleting a file, its\nTTL, OCR and shared links need `owner`, `admin` or `editor` (`files.orgScope.writeRoles`), or being the\nuploader. Outside its workspace a workspace file is a `404`, for the uploader too. Erasing the uploader keeps\nthe file; deleting the workspace deletes its files in the background.\n\n## Billing the workspace\n\nThe plan is a **payments integration**: your own Stripe, Paddle or PayPal account. vxil records what the\nprovider reports and is never in the flow of funds. This blueprint runs on `mock` until you add your\nprovider block and replace `pri_team_monthly` in `ledger.priceMap` with your real price id.\n\n- **`free`.** `create` grants it once to every new workspace (a manual, open-ended plan whose subject is the\n workspace).\n- **`team`.** A workspace admin opens the checkout through the `workspace-billing` function:\n `const { url } = await vx.fn['workspace-billing']({ org_id })`, then `location.assign(url)`. The checkout's\n subject is the workspace, so the subscription folds onto `org:<workspace id>`. It outranks `free` (`rank`\n 10 against 0).\n- **Reading it.** Any member reads the active workspace's plan:\n `await vx.payments.hasEntitlement({ subject: 'org' }, 'team')`. Your server reads any workspace's plan with\n `{ org_id }`.\n\n## Costs and limits\n\n- **Reads cost nothing extra.** The workspace is one more indexed condition in the same query: no added round\n trip, no added read.\n- **A membership change** is one function invocation (at most 4 reads and 2 writes against your own backend,\n no fan-out). The billing function is one read and one write. On a Free `staging` project, functions run\n inside 10,000 invocations and 500,000 CPU-ms a day. A production project runs functions from Developer up.\n- **Change feeds** publish one frame per changed row, ids only. Realtime sockets per project follow your\n plan: 500 on Free, 1,000 on Developer, 5,000 on Team.\n- **Files** count towards the project's storage quota like every file (Free 10 GiB, Developer 25 GiB, Team\n 150 GiB).\n- **Payments.** vxil adds no fee to the money flow. Your provider charges its own fees.\n\n## What to learn from this\n\n- **The workspace boundary is config, not code.** `orgField` puts the workspace predicate on every end-user\n read, write, count, join, cascade and change feed. Your server key is never affected.\n- **A membership is a data boundary, so it changes on the server.** The workspaceGuard pattern is three\n lines: who (`env.end_user`), may they (`/check` with their own token), then act (`server_jwts`).\n- **The workspace is the customer.** It holds the plan, the files and the rows, so people can come and go\n without anything being re-keyed.\n\n**Pairs with:** **team-workspace** (orgs with SSO, field-level security and one action button, without the\nactive workspace) and **ai-knowledge-base** (this workspace model plus search and grounded answers).\n",
21233
21426
  "functions": {
21234
21427
  "workspace-billing.ts": "// workspace-billing.ts \u2014 open the TEAM checkout for a workspace (a vxil function).\n//\n// Called by the app AS the signed-in user:\n// const { url } = await vx.fn['workspace-billing']({ org_id }) // then location.assign(url)\n//\n// It is a payments INTEGRATION: the checkout is your own provider's (Stripe,\n// Paddle, PayPal); vxil opens it and later records what the provider reports.\n// vxil is never in the flow of funds.\n//\n// The checkout's SUBJECT is the workspace (`org_id`), so the provider event folds\n// onto `org:<org_id>`: the `team` tier belongs to the workspace, every member\n// reads it (`vx.payments.hasEntitlement({ subject: 'org' }, 'team')`), and it\n// stays when the workspace changes owner. The paying member rides along as\n// `user_id` \u2014 tagged on the checkout, never entitled.\n//\n// Only an admin or owner of THAT workspace may open it (checked with the\n// caller's own token, so it is the verified identity and the live membership).\n// The price is fixed here, never taken from the payload. Bounds: one read, one\n// write per call.\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ntype Env = HttpFunctionEnvelope<{ org_id?: string; success_url?: string; cancel_url?: string }>;\n\n/** Your provider's price id for the team plan \u2014 the `priceMap` key in vxil.config.ts. */\nconst TEAM_PRICE = 'pri_team_monthly';\n/** Where the provider sends the payer back. Replace with your app's pages. */\nconst APP_ORIGIN = 'https://app.example.com';\n\nconst fail = (status: number, code: string, message: string): Response =>\n Response.json({ error: { code, message } }, { status });\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const me = env.end_user;\n if (!me || me.anon) return fail(401, 'sign_in_required', 'call this function with a signed-in user session');\n const userOrgs = env.scoped_jwts?.orgs;\n const pay = env.server_jwts?.payments;\n if (!userOrgs || !pay) return fail(500, 'misconfigured', 'orgs:read scope or payments:write serverScope is missing');\n const orgId = String(env.payload?.org_id ?? '');\n if (!orgId) return fail(422, 'validation_failed', 'need { org_id }');\n\n // workspaceGuard: billing is an admin decision in THIS workspace\n const check = await fetch(\n `${base}/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(me.id)}&permission=members:manage`,\n { headers: { authorization: `Bearer ${userOrgs}` } },\n );\n const verdict = ((await check.json().catch(() => ({}))) as { data?: { allowed?: boolean } }).data;\n if (!check.ok || verdict?.allowed !== true) return fail(403, 'forbidden', 'only a workspace admin or owner may change the plan');\n\n // Only your own pages may be return targets.\n const back = (u: unknown, fallback: string): string =>\n typeof u === 'string' && u.startsWith(`${APP_ORIGIN}/`) ? u : fallback;\n\n const sess = await fetch(`${base}/v1/payments/checkout-sessions`, {\n method: 'POST',\n // One key per INVOCATION (the runtime's per-call key, or the caller's\n // Idempotency-Key header): a retry of this call replays the same session,\n // and a later purchase gets a fresh one.\n headers: { authorization: `Bearer ${pay}`, 'content-type': 'application/json', 'idempotency-key': env.idempotency_key },\n body: JSON.stringify({\n org_id: orgId, // the workspace gets the plan\n user_id: me.id, // the member paying (tagged, not entitled)\n mode: 'subscription',\n line_items: [{ price_ref: TEAM_PRICE, quantity: 1 }],\n success_url: back(env.payload?.success_url, `${APP_ORIGIN}/billing/ok`),\n cancel_url: back(env.payload?.cancel_url, `${APP_ORIGIN}/billing`),\n }),\n });\n const out = (await sess.json().catch(() => ({}))) as { data?: { url?: string }; error?: { code?: string; message?: string } };\n if (!sess.ok || !out.data?.url) {\n return fail(sess.status >= 400 ? sess.status : 502, out.error?.code ?? 'checkout_failed', out.error?.message ?? 'the provider checkout could not be opened');\n }\n return Response.json({ url: out.data.url });\n },\n};\n",
21235
21428
  "workspace-members.ts": "// workspace-members.ts \u2014 every MEMBERSHIP change of a workspace (a vxil function).\n//\n// Why a function: in active-org mode (auth.orgClaims.activeOrg) an org\n// membership IS the data boundary \u2014 it decides which workspace rows a session\n// reads \u2014 so an end-user session gets 403 server_only on every orgs write. The\n// app calls this function AS the signed-in user instead:\n//\n// await vx.fn['workspace-members']({ action: 'invite', org_id, role: 'editor' })\n//\n// and the function applies the product's rules in a few lines (the\n// \"workspaceGuard\" pattern):\n// 1. WHO is calling comes from `env.end_user` \u2014 the identity the platform\n// verified \u2014 never from the payload.\n// 2. WHAT they may do is asked with THEIR OWN token: `GET /v1/orgs/:id/check`\n// in end-user mode always checks the verified caller, and it reads the\n// live membership (revocation-grade, not the session snapshot).\n// 3. The write itself uses `server_jwts` \u2014 tokens NOT bound to the user,\n// limited to the trigger's `serverScopes`.\n//\n// Actions (payload.action):\n// create { slug, name } \u2192 a new workspace, the caller its owner, on the `free` plan\n// invite { org_id, role } \u2192 a single-use LINK invite (admin+); role admin|editor|viewer\n// accept { token } \u2192 the caller takes the invited seat\n// set_role { org_id, user_id, role } \u2192 change a member's role (admin+; owner changes need the owner)\n// remove { org_id, user_id } \u2192 remove a member (admin+), or leave (user_id = yourself)\n//\n// Bounds (per call): at most 4 reads and 2 writes against your own backend; no\n// fan-out, no loop. A seat check reads one members page and one invitations\n// page (both bounded by orgs.maxMembersPerOrg and the 200-row invitation list).\n\nimport type { HttpFunctionEnvelope } from '@vxil/sdk';\n\ninterface Body {\n action?: string;\n org_id?: string;\n slug?: string;\n name?: string;\n role?: string;\n user_id?: string;\n token?: string;\n}\ntype Env = HttpFunctionEnvelope<Body>;\n\n/** Roles an invite or a role change may hand out. `owner` is never invited;\n * making someone an owner is a `set_role` by an owner. */\nconst INVITABLE = ['admin', 'editor', 'viewer'];\nconst ASSIGNABLE = ['owner', ...INVITABLE];\n/** Workspaces one user may own \u2014 the project-wide `orgs.maxOrgsPerTenant` is\n * shared by everyone, so one account cannot exhaust it. */\nconst MAX_OWNED_WORKSPACES = 5;\n/** Seats when the workspace has no plan row yet (= the `free` tier's quota). */\nconst FREE_SEATS = 3;\n\nconst fail = (status: number, code: string, message: string): Response =>\n Response.json({ error: { code, message } }, { status });\n\nexport default {\n async fetch(req: Request): Promise<Response> {\n const env = (await req.json().catch(() => ({}))) as Env;\n const base = env.vxil_base ?? 'https://api.vxil.com';\n const me = env.end_user;\n // A membership change always has a person behind it: a signed-in,\n // registered user (a guest session cannot own or join a workspace).\n if (!me || me.anon) return fail(401, 'sign_in_required', 'call this function with a signed-in user session');\n const userOrgs = env.scoped_jwts?.orgs; // acts AS the caller\n const orgs = env.server_jwts?.orgs; // acts as your backend\n const pay = env.server_jwts?.payments;\n if (!userOrgs || !orgs || !pay) return fail(500, 'misconfigured', 'orgs/payments scopes or serverScopes are missing');\n\n const call = async (token: string, method: string, path: string, body?: unknown) => {\n const res = await fetch(`${base}${path}`, {\n method,\n headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },\n ...(body ? { body: JSON.stringify(body) } : {}),\n });\n const json = (await res.json().catch(() => ({}))) as { data?: Record<string, unknown>; error?: { code?: string; message?: string } };\n return { status: res.status, ok: res.ok, data: json.data ?? {}, error: json.error };\n };\n /** Pass a refusal from the backend straight through (409 last owner, 422 cap, \u2026). */\n const relay = (r: { status: number; error?: { code?: string; message?: string } | undefined }) =>\n fail(r.status, r.error?.code ?? 'upstream_error', r.error?.message ?? `request failed (${r.status})`);\n\n /** workspaceGuard: does the CALLER hold `permission` in `orgId` right now? */\n const guard = async (orgId: string, permission: string): Promise<{ allowed: boolean; role: string | null }> => {\n const r = await call(userOrgs, 'GET',\n `/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(me.id)}&permission=${encodeURIComponent(permission)}`);\n return { allowed: r.ok && r.data.allowed === true, role: typeof r.data.role === 'string' ? r.data.role : null };\n };\n /** A member's current role in `orgId` (null = not a member). Server read. */\n const roleOf = async (orgId: string, userId: string): Promise<string | null> => {\n const r = await call(orgs, 'GET',\n `/v1/orgs/${encodeURIComponent(orgId)}/check?user_id=${encodeURIComponent(userId)}&permission=org:read`);\n return r.ok && typeof r.data.role === 'string' ? r.data.role : null;\n };\n\n const p = env.payload ?? {};\n const orgId = typeof p.org_id === 'string' ? p.org_id : '';\n\n switch (p.action) {\n case 'create': {\n if (typeof p.slug !== 'string' || typeof p.name !== 'string' || !p.name.trim()) {\n return fail(422, 'validation_failed', 'need { slug, name } (slug: a-z, 0-9 and -)');\n }\n // Counts owned workspaces among the user's 100 newest memberships (one\n // read, no paging): someone in more than 100 workspaces may get past\n // the cap. A failed read refuses the create (fail closed).\n const mine = await call(orgs, 'GET', `/v1/orgs?user_id=${encodeURIComponent(me.id)}&limit=100`);\n if (!mine.ok) return relay(mine);\n const owned = ((mine.data.orgs as Array<{ role?: string }> | undefined) ?? []).filter((o) => o.role === 'owner');\n if (owned.length >= MAX_OWNED_WORKSPACES) {\n return fail(422, 'workspace_limit', `you already own ${MAX_OWNED_WORKSPACES} workspaces`);\n }\n const made = await call(orgs, 'POST', '/v1/orgs', { slug: p.slug, name: p.name.trim(), owner_user_id: me.id });\n if (!made.ok) return relay(made);\n const newOrg = String(made.data.org_id);\n // Every workspace starts on the `free` tier: a manual, open-ended grant\n // whose subject is the WORKSPACE. Idempotent on the key, so a retry is safe.\n const grant = await call(pay, 'POST', '/v1/payments/subscriptions/grant', {\n org_id: newOrg, tier: 'free', reason: 'workspace created', idempotency_key: `free:${newOrg}`,\n });\n // The app now moves the session into it: vx.auth.sessions.switchOrg(org_id)\n return Response.json({ org_id: newOrg, slug: made.data.slug, plan: grant.ok ? 'free' : null }, { status: 201 });\n }\n\n case 'invite': {\n const role = String(p.role ?? '');\n if (!orgId || !INVITABLE.includes(role)) return fail(422, 'validation_failed', `need { org_id, role } with role ${INVITABLE.join('|')}`);\n if (!(await guard(orgId, 'members:manage')).allowed) return fail(403, 'forbidden', 'only a workspace admin or owner may invite');\n // The PLAN's seats: members + still-pending invitations must stay below it.\n const [ent, members, invites] = await Promise.all([\n call(pay, 'GET', `/v1/payments/entitlements?org_id=${encodeURIComponent(orgId)}`),\n call(orgs, 'GET', `/v1/orgs/${encodeURIComponent(orgId)}/members`),\n call(orgs, 'GET', `/v1/orgs/${encodeURIComponent(orgId)}/invitations`),\n ]);\n const quota = (ent.data.quotas as Record<string, number> | undefined)?.seats;\n const seats = typeof quota === 'number' ? quota : FREE_SEATS;\n const taken = ((members.data.members as unknown[] | undefined) ?? []).length\n + ((invites.data.invitations as Array<{ pending?: boolean }> | undefined) ?? []).filter((i) => i.pending).length;\n if (taken >= seats) return fail(402, 'seats_exhausted', `the workspace plan has ${seats} seats and all are taken or invited`);\n // A LINK invite (no email): vxil sends nothing, the token is returned\n // ONCE, and your app builds the accept link and delivers it.\n const inv = await call(orgs, 'POST', `/v1/orgs/${encodeURIComponent(orgId)}/invitations`, { role });\n if (!inv.ok) return relay(inv);\n return Response.json({ invite_id: inv.data.invite_id, role, invite_token: inv.data.invite_token }, { status: 201 });\n }\n\n case 'accept': {\n if (typeof p.token !== 'string' || !p.token) return fail(422, 'validation_failed', 'need { token }');\n // The seat goes to the VERIFIED caller, never to a user id in the payload.\n const acc = await call(orgs, 'POST', '/v1/orgs/invitations/accept', { token: p.token, user_id: me.id });\n if (!acc.ok) return relay(acc);\n // The app now moves the session into it: vx.auth.sessions.switchOrg(org_id)\n return Response.json({ org_id: acc.data.org_id, role: acc.data.role });\n }\n\n case 'set_role': {\n const role = String(p.role ?? '');\n const target = String(p.user_id ?? '');\n if (!orgId || !target || !ASSIGNABLE.includes(role)) {\n return fail(422, 'validation_failed', `need { org_id, user_id, role } with role ${ASSIGNABLE.join('|')}`);\n }\n const current = await roleOf(orgId, target);\n // Only an existing member changes role \u2014 this action never adds a seat.\n if (!current) return fail(404, 'not_found', 'that user is not a member of the workspace');\n const needs = role === 'owner' || current === 'owner' ? 'org:admin' : 'members:manage';\n if (!(await guard(orgId, needs)).allowed) {\n return fail(403, 'forbidden', needs === 'org:admin' ? 'only an owner may grant or take the owner role' : 'only a workspace admin or owner may change roles');\n }\n // An org always keeps one owner: demoting the last one is a 409 from orgs.\n const set = await call(orgs, 'POST', `/v1/orgs/${encodeURIComponent(orgId)}/members`, { user_id: target, role });\n if (!set.ok) return relay(set);\n // The member's live sessions are marked stale; their next request\n // refreshes onto the new role in about 60 s.\n return Response.json({ org_id: orgId, user_id: target, role, previous: current });\n }\n\n case 'remove': {\n const target = String(p.user_id ?? '');\n if (!orgId || !target) return fail(422, 'validation_failed', 'need { org_id, user_id }');\n if (target !== me.id) {\n const current = await roleOf(orgId, target);\n if (!current) return fail(404, 'not_found', 'that user is not a member of the workspace');\n if (!(await guard(orgId, current === 'owner' ? 'org:admin' : 'members:manage')).allowed) {\n return fail(403, 'forbidden', 'only a workspace admin (or an owner, for an owner) may remove members');\n }\n }\n const del = await call(orgs, 'DELETE', `/v1/orgs/${encodeURIComponent(orgId)}/members/${encodeURIComponent(target)}`);\n if (!del.ok) return relay(del);\n return Response.json({ org_id: orgId, removed: target });\n }\n\n default:\n return fail(422, 'validation_failed', 'action must be create | invite | accept | set_role | remove');\n }\n },\n};\n"
@@ -22720,6 +22913,7 @@ function pickAuthColumns(available) {
22720
22913
  const have = new Set(available);
22721
22914
  return AUTH_USERS_SAFE_COLUMNS.filter((c) => have.has(c));
22722
22915
  }
22916
+ var UTC_ISO = (col) => `to_char(${col} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
22723
22917
  var supabaseSql = {
22724
22918
  /** does this database carry the GoTrue auth schema? */
22725
22919
  authUsersProbe: () => ({
@@ -22752,7 +22946,7 @@ var supabaseSql = {
22752
22946
  const n = Math.max(1, Math.trunc(limit));
22753
22947
  const conds = [...after !== null ? ["id > $1::uuid"] : [], ...hasDeletedAt ? ["deleted_at IS NULL"] : []];
22754
22948
  return {
22755
- text: `SELECT id::text AS id, encrypted_password, (email_confirmed_at IS NOT NULL) AS email_verified FROM auth.users${conds.length ? ` WHERE ${conds.join(" AND ")}` : ""} ORDER BY id ASC LIMIT ${n}`,
22949
+ text: `SELECT id::text AS id, encrypted_password, (email_confirmed_at IS NOT NULL) AS email_verified, ${UTC_ISO("created_at")} AS created_at, ${UTC_ISO("email_confirmed_at")} AS email_verified_at FROM auth.users${conds.length ? ` WHERE ${conds.join(" AND ")}` : ""} ORDER BY id ASC LIMIT ${n}`,
22756
22950
  params: after !== null ? [after] : []
22757
22951
  };
22758
22952
  },
@@ -22895,6 +23089,8 @@ var SupabaseAdapter = class extends GenericPgAdapter {
22895
23089
  const out = rows.map((r) => {
22896
23090
  const h = typeof r.encrypted_password === "string" && r.encrypted_password.length > 0 ? r.encrypted_password : null;
22897
23091
  const row2 = { id: String(r.id), password_hash: h, email_verified: r.email_verified === true };
23092
+ if (typeof r.created_at === "string") row2.created_at = r.created_at;
23093
+ if (typeof r.email_verified_at === "string") row2.email_verified_at = r.email_verified_at;
22898
23094
  if (subjectColumn !== null) row2.identities = links.get(String(r.id)) ?? [];
22899
23095
  return row2;
22900
23096
  });
@@ -23654,6 +23850,9 @@ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config fo
23654
23850
  compared.collections += Object.keys(cfg.cms.collections).length;
23655
23851
  });
23656
23852
  const { functions: _fnFeature, ...configFeatures } = cfg.features;
23853
+ for (const c of paymentsShadowChecks(cfg.features?.payments)) {
23854
+ console.error(`warning: ${c.detail}`);
23855
+ }
23657
23856
  const serverApiState = {};
23658
23857
  await runSection("config", async () => {
23659
23858
  if (!Object.keys(configFeatures).length) return;
@@ -24392,6 +24591,7 @@ async function runDoctor() {
24392
24591
  const pres = await api("GET", "/v1/config/payments");
24393
24592
  if (pres.status === 200) {
24394
24593
  checks.push(...paymentsWebhookSecretChecks(pres.body.data?.manifest));
24594
+ checks.push(...paymentsShadowChecks(pres.body.data?.manifest));
24395
24595
  }
24396
24596
  }
24397
24597
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/cli",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "private": false,
5
5
  "description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
6
6
  "license": "MIT",
@@ -45,8 +45,8 @@
45
45
  }
46
46
  },
47
47
  "devDependencies": {
48
- "@vxil/config": "0.20.0",
49
- "@vxil/feature-configs": "0.18.0",
48
+ "@vxil/config": "0.22.0",
49
+ "@vxil/feature-configs": "0.20.0",
50
50
  "@vxil/runtime": "0.0.1",
51
51
  "@vxil/types": "0.0.1",
52
52
  "miniflare": "^4.20260730.0"