@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.
- package/dist/_vxil-feature-configs-readmodels.d.ts +30 -0
- package/dist/_vxil-feature-configs.d.ts +41 -4
- package/dist/config.d.ts +22 -2
- package/dist/vxil-main.js +227 -27
- package/package.json +3 -3
|
@@ -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
|
-
*
|
|
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
|
|
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.
|
|
5588
|
-
var VXIL_SDK_PKG_VERSION = "0.
|
|
5589
|
-
var VXIL_CLI_PKG_VERSION = "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:
|
|
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:**
|
|
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.
|
|
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.
|
|
49
|
-
"@vxil/feature-configs": "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"
|