@vxil/cli 0.24.1 → 0.25.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-apiState.d.ts +77 -2
- package/dist/_vxil-feature-configs-hooks.d.ts +7 -0
- package/dist/_vxil-feature-configs.d.ts +52 -0
- package/dist/config.d.ts +70 -0
- package/dist/config.js +144 -2
- package/dist/vxil-main.js +720 -59
- package/package.json +3 -3
package/dist/vxil-main.js
CHANGED
|
@@ -3182,7 +3182,8 @@ var API_STATE_DATUMS = [
|
|
|
3182
3182
|
{ feature: "rate-limits", datum: "policies" },
|
|
3183
3183
|
{ feature: "webhooks", datum: "subscriptions" },
|
|
3184
3184
|
{ feature: "ai", datum: "templates" },
|
|
3185
|
-
{ feature: "orgs", datum: "roles" }
|
|
3185
|
+
{ feature: "orgs", datum: "roles" },
|
|
3186
|
+
{ feature: "vector-search", datum: "collections" }
|
|
3186
3187
|
];
|
|
3187
3188
|
function apiStateDatumOf(feature) {
|
|
3188
3189
|
return API_STATE_DATUMS.find((d) => d.feature === feature)?.datum ?? null;
|
|
@@ -3311,6 +3312,81 @@ function planOrgRoles(declared, live) {
|
|
|
3311
3312
|
for (const l of live) if (!declaredKeys.has(l.role_key)) plan.undeclared.push(l.role_key);
|
|
3312
3313
|
return plan;
|
|
3313
3314
|
}
|
|
3315
|
+
var KEYED_PROVIDERS = /* @__PURE__ */ new Set(["openai", "cohere"]);
|
|
3316
|
+
function planSearchCollections(declared, live) {
|
|
3317
|
+
const plan = { create: [], update: [], unchanged: [], immutable: [], refused: [], undeclared: [] };
|
|
3318
|
+
const byName = new Map(live.map((l) => [l.collection, l]));
|
|
3319
|
+
const declaredNames = new Set(declared.map((d) => d.collection));
|
|
3320
|
+
for (const d of declared) {
|
|
3321
|
+
const l = byName.get(d.collection);
|
|
3322
|
+
if (!l) {
|
|
3323
|
+
plan.create.push({
|
|
3324
|
+
collection: d.collection,
|
|
3325
|
+
...d.dimensions !== void 0 ? { dimensions: d.dimensions } : {},
|
|
3326
|
+
...d.embed && Object.keys(d.embed).length ? { embed: { ...d.embed } } : {}
|
|
3327
|
+
});
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
if (d.dimensions !== void 0 && d.dimensions !== l.dimensions) {
|
|
3331
|
+
plan.immutable.push({ collection: d.collection, declared: d.dimensions, live: l.dimensions });
|
|
3332
|
+
continue;
|
|
3333
|
+
}
|
|
3334
|
+
const e = d.embed ?? {};
|
|
3335
|
+
const changed = [];
|
|
3336
|
+
const providerChanged = e.provider !== void 0 && e.provider !== (l.provider ?? null);
|
|
3337
|
+
if (providerChanged) changed.push("provider");
|
|
3338
|
+
if (e.model !== void 0 && l.model !== void 0 && e.model !== (l.model ?? null)) changed.push("model");
|
|
3339
|
+
if (e.apiKeyRef !== void 0 && l.api_key_ref !== void 0 && e.apiKeyRef !== (l.api_key_ref ?? null)) changed.push("apiKeyRef");
|
|
3340
|
+
if (!changed.length) {
|
|
3341
|
+
plan.unchanged.push(d.collection);
|
|
3342
|
+
continue;
|
|
3343
|
+
}
|
|
3344
|
+
let embed;
|
|
3345
|
+
if (providerChanged) {
|
|
3346
|
+
const missing = KEYED_PROVIDERS.has(e.provider) ? ["model", "apiKeyRef"].filter((k) => e[k] === void 0) : [];
|
|
3347
|
+
if (missing.length) {
|
|
3348
|
+
plan.refused.push({
|
|
3349
|
+
collection: d.collection,
|
|
3350
|
+
error: `provider change to ${e.provider} must also declare embed.${missing.join(" and embed.")} \u2014 the live ${l.provider ?? "default"} settings are never carried over to a new provider (and the new model must output the collection's ${l.dimensions} dimensions)`
|
|
3351
|
+
});
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
3354
|
+
embed = { ...e };
|
|
3355
|
+
} else {
|
|
3356
|
+
const provider = e.provider ?? l.provider ?? void 0;
|
|
3357
|
+
const model = e.model ?? l.model ?? void 0;
|
|
3358
|
+
const apiKeyRef = e.apiKeyRef ?? l.api_key_ref ?? void 0;
|
|
3359
|
+
embed = { ...provider ? { provider } : {}, ...model ? { model } : {}, ...apiKeyRef ? { apiKeyRef } : {} };
|
|
3360
|
+
}
|
|
3361
|
+
const reembed = (changed.includes("provider") || changed.includes("model")) && (l.documents ?? 1) > 0;
|
|
3362
|
+
plan.update.push({ body: { collection: d.collection, dimensions: l.dimensions, embed }, changed, reembed });
|
|
3363
|
+
}
|
|
3364
|
+
for (const l of live) if (!declaredNames.has(l.collection)) plan.undeclared.push(l.collection);
|
|
3365
|
+
return plan;
|
|
3366
|
+
}
|
|
3367
|
+
function searchCollectionImmutableError(i) {
|
|
3368
|
+
return `422 dimensions_immutable: declared dimensions ${i.declared} but the live collection has ${i.live} \u2014 dimensions are immutable; declare a new collection name and re-ingest`;
|
|
3369
|
+
}
|
|
3370
|
+
function pulledSearchCollections(rows) {
|
|
3371
|
+
if (rows.length > SEARCH_COLLECTION_PULL_CAP) {
|
|
3372
|
+
return { note: ` vector-search.collections: the tenant has ${rows.length} search collections, past the ${SEARCH_COLLECTION_PULL_CAP}-entry cap of a declared collections[] \u2014 not written; they stay live rows` };
|
|
3373
|
+
}
|
|
3374
|
+
return {
|
|
3375
|
+
collections: rows.map((r) => {
|
|
3376
|
+
const embed = {};
|
|
3377
|
+
if (typeof r.provider === "string") embed.provider = r.provider;
|
|
3378
|
+
if (typeof r.model === "string") embed.model = r.model;
|
|
3379
|
+
if (typeof r.api_key_ref === "string") embed.apiKeyRef = r.api_key_ref;
|
|
3380
|
+
return {
|
|
3381
|
+
collection: String(r.collection),
|
|
3382
|
+
// the server only ever stores an allowed width
|
|
3383
|
+
...typeof r.dimensions === "number" ? { dimensions: r.dimensions } : {},
|
|
3384
|
+
...Object.keys(embed).length ? { embed } : {}
|
|
3385
|
+
};
|
|
3386
|
+
})
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
var SEARCH_COLLECTION_PULL_CAP = 50;
|
|
3314
3390
|
function emptyApiStateSummary(datum) {
|
|
3315
3391
|
return {
|
|
3316
3392
|
datum,
|
|
@@ -3355,7 +3431,9 @@ function formatApiStateDetail(s) {
|
|
|
3355
3431
|
for (const k of s.updated) lines.push(` ~ ${k}`);
|
|
3356
3432
|
for (const k of s.recreated) lines.push(` \u21BB ${k} (recreated \u2014 new id)`);
|
|
3357
3433
|
for (const k of s.deleted) lines.push(` - ${k} (deleted: undeclared, --allow-destructive)`);
|
|
3358
|
-
for (const k of s.undeclared)
|
|
3434
|
+
for (const k of s.undeclared) {
|
|
3435
|
+
lines.push(s.datum === "collections" ? ` \xB7 ${k} \u2014 live but not declared (left in place; a push never deletes a search collection)` : ` \xB7 ${k} \u2014 live but not declared (left in place; --allow-destructive deletes it)`);
|
|
3436
|
+
}
|
|
3359
3437
|
for (const k of s.pending_destructive) lines.push(` ! ${k} \u2014 needs delete+recreate (new id); re-run with --allow-destructive`);
|
|
3360
3438
|
for (const k of s.ambiguous) lines.push(` ! ${k} \u2014 more than one live row carries this name; delete the extras first`);
|
|
3361
3439
|
for (const f of s.failed) lines.push(` \u2717 ${f.key} \u2014 ${f.error}`);
|
|
@@ -3369,6 +3447,9 @@ var PUBLIC_ASSET_TYPES = {
|
|
|
3369
3447
|
webp: "image/webp",
|
|
3370
3448
|
avif: "image/avif",
|
|
3371
3449
|
gif: "image/gif",
|
|
3450
|
+
// Favicons. A raster container (BMP/PNG frames) — no
|
|
3451
|
+
// script, rendered only as an image; never a variant source.
|
|
3452
|
+
ico: "image/x-icon",
|
|
3372
3453
|
mp4: "video/mp4",
|
|
3373
3454
|
webm: "video/webm",
|
|
3374
3455
|
mp3: "audio/mpeg",
|
|
@@ -3382,6 +3463,7 @@ var TYPE_TO_EXT = {
|
|
|
3382
3463
|
...Object.fromEntries(Object.entries(PUBLIC_ASSET_TYPES).map(([ext, ct]) => [ct, ext])),
|
|
3383
3464
|
"image/jpg": "jpg",
|
|
3384
3465
|
"image/pjpeg": "jpg",
|
|
3466
|
+
"image/vnd.microsoft.icon": "ico",
|
|
3385
3467
|
"audio/mp3": "mp3",
|
|
3386
3468
|
"audio/x-m4a": "m4a",
|
|
3387
3469
|
"audio/m4a": "m4a",
|
|
@@ -3917,7 +3999,17 @@ var AuthConfigSchema = Type.Object({
|
|
|
3917
3999
|
// OPTIONAL bag = 1 leaf. Lives in AUTH config (not orgs) because auth-v1's
|
|
3918
4000
|
// CONFIG_KV is the auth namespace and cannot see orgs config.
|
|
3919
4001
|
orgClaims: Type.Optional(Type.Object({
|
|
3920
|
-
enabled: Type.Boolean({ default: false })
|
|
4002
|
+
enabled: Type.Boolean({ default: false }),
|
|
4003
|
+
// ACTIVE-ORG mode. Every mint/refresh stamps the session claim
|
|
4004
|
+
// `oam: 1`, the session row stores the org it acts in (active_org_id: the
|
|
4005
|
+
// most-recent membership at first mint, or the org picked by
|
|
4006
|
+
// POST /v1/auth/sessions/refresh { org_id }), a refresh never falls back to
|
|
4007
|
+
// another org, the org role is no longer folded into `roles`, membership
|
|
4008
|
+
// changes mark live sessions `claims_stale`, and orgs membership writes are
|
|
4009
|
+
// server-only for end users. Needs `enabled`. Optional WITHOUT a default
|
|
4010
|
+
// (absent = off) so existing manifests fold byte-identically; inside the
|
|
4011
|
+
// existing bag, so no new config leaf.
|
|
4012
|
+
activeOrg: Type.Optional(Type.Boolean())
|
|
3921
4013
|
})),
|
|
3922
4014
|
// Note: the earlier `redirects` block was DROPPED entirely —
|
|
3923
4015
|
// it had zero consumers (grep-verified: no worker reads config.redirects) and
|
|
@@ -3935,8 +4027,9 @@ var AuthConfigSchema = Type.Object({
|
|
|
3935
4027
|
// chars leave the worker, never the password) at sign-up / reset-confirm;
|
|
3936
4028
|
// fail-OPEN on network error → 422 password_breached.
|
|
3937
4029
|
// • captchaSecretRef — a secret-store ref (feature 'auth') holding the
|
|
3938
|
-
// Turnstile secret; when set, sign-up / OTP request / magic-link request
|
|
3939
|
-
//
|
|
4030
|
+
// Turnstile secret; when set, sign-up / OTP request / magic-link request /
|
|
4031
|
+
// email-verification request / password-reset request require
|
|
4032
|
+
// `captcha_token` (403 captcha_failed otherwise).
|
|
3940
4033
|
// • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
|
|
3941
4034
|
// redirect/return URL (magic link, password reset, email verification,
|
|
3942
4035
|
// OAuth start) must match one origin exactly → 422 redirect_not_allowed.
|
|
@@ -3971,6 +4064,14 @@ var AuthConfigSchema = Type.Object({
|
|
|
3971
4064
|
// account is never affected. The generic OIDC issuer keeps its own gate
|
|
3972
4065
|
// (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
|
|
3973
4066
|
inviteOnly: Type.Optional(Type.Boolean()),
|
|
4067
|
+
// Squatted-address defence (guide ch. 9): a verify-link confirm with NO
|
|
4068
|
+
// matching session proves the mailbox only. With verifyLinkEvicts:true it
|
|
4069
|
+
// runs the eviction guard (clears a never-verified row's password, revokes
|
|
4070
|
+
// its sessions, drops its provider links — the squatter defence); absent /
|
|
4071
|
+
// false keeps the password, so a genuine sign-up who opens the link in a
|
|
4072
|
+
// mail-app browser (or an SDK that sends no session_token) is not locked
|
|
4073
|
+
// out. Optional, NO default: every existing manifest folds byte-identically.
|
|
4074
|
+
verifyLinkEvicts: Type.Optional(Type.Boolean()),
|
|
3974
4075
|
allowedEmailDomains: Type.Optional(Type.Array(
|
|
3975
4076
|
Type.String({ minLength: 3, maxLength: 253, pattern: "^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$" }),
|
|
3976
4077
|
{ maxItems: 32 }
|
|
@@ -4306,7 +4407,10 @@ var DeclaredOrgRoleSchema = Type.Object({
|
|
|
4306
4407
|
});
|
|
4307
4408
|
var OrgsConfigSchema = Type.Object({
|
|
4308
4409
|
enabled: Type.Boolean({ default: true }),
|
|
4309
|
-
|
|
4410
|
+
// Org cap: max 100,000 (was 10,000); the default stays 100 so
|
|
4411
|
+
// existing manifests fold byte-identically. The unpaged GET /v1/orgs page is
|
|
4412
|
+
// clamped at 10,000 (orgs-v1 parseOrgsPage), so no response grows.
|
|
4413
|
+
maxOrgsPerTenant: Type.Integer({ default: 100, minimum: 1, maximum: 1e5 }),
|
|
4310
4414
|
maxMembersPerOrg: Type.Integer({ default: 1e3, minimum: 1, maximum: 1e5 }),
|
|
4311
4415
|
invitationTtlHours: Type.Integer({ default: 168, minimum: 1, maximum: 720 }),
|
|
4312
4416
|
// Declared API state (G-31): the tenant's CUSTOM role catalog. Converged by
|
|
@@ -4387,6 +4491,22 @@ var ActivityFeedConfigSchema = Type.Object({
|
|
|
4387
4491
|
// per-tenant request meter are the real bounds). Re-declare it together with
|
|
4388
4492
|
// the binding that enforces it.
|
|
4389
4493
|
});
|
|
4494
|
+
var SEARCH_COLLECTION_DECLARED_CAP = 50;
|
|
4495
|
+
var SEARCH_COLLECTION_DIMS = [256, 384, 512, 768, 1024, 1536, 3072];
|
|
4496
|
+
var DeclaredSearchCollectionSchema = Type.Object({
|
|
4497
|
+
collection: Type.String({ pattern: "^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$" }),
|
|
4498
|
+
dimensions: Type.Optional(Type.Union(SEARCH_COLLECTION_DIMS.map((d) => Type.Literal(d)))),
|
|
4499
|
+
embed: Type.Optional(Type.Object({
|
|
4500
|
+
provider: Type.Optional(Type.Union([
|
|
4501
|
+
Type.Literal("byov"),
|
|
4502
|
+
Type.Literal("mock"),
|
|
4503
|
+
Type.Literal("openai"),
|
|
4504
|
+
Type.Literal("cohere")
|
|
4505
|
+
])),
|
|
4506
|
+
model: Type.Optional(Type.String({ maxLength: 128 })),
|
|
4507
|
+
apiKeyRef: Type.Optional(Type.String({ maxLength: 128 }))
|
|
4508
|
+
}))
|
|
4509
|
+
});
|
|
4390
4510
|
var VectorSearchConfigSchema = Type.Object({
|
|
4391
4511
|
enabled: Type.Boolean({ default: true }),
|
|
4392
4512
|
// 'auto' resolves to the default managed vector backend for the tier.
|
|
@@ -4474,7 +4594,14 @@ var VectorSearchConfigSchema = Type.Object({
|
|
|
4474
4594
|
}),
|
|
4475
4595
|
{ maxItems: 8 }
|
|
4476
4596
|
)
|
|
4477
|
-
)
|
|
4597
|
+
),
|
|
4598
|
+
// Declared API state: the tenant's search
|
|
4599
|
+
// collections, converged by `collection` name by `vxil push` / POST
|
|
4600
|
+
// /v1/apply (control-plane handlers/apiState.ts) through POST
|
|
4601
|
+
// /v1/search/collections. Undeclared live collections are left and
|
|
4602
|
+
// reported — there is no collection delete route, so never deleted.
|
|
4603
|
+
// Absent = not managed by config. ARRAY = ONE leaf.
|
|
4604
|
+
collections: Type.Optional(Type.Array(DeclaredSearchCollectionSchema, { maxItems: SEARCH_COLLECTION_DECLARED_CAP }))
|
|
4478
4605
|
});
|
|
4479
4606
|
var AI_TEMPLATE_NAME_PATTERN = "^[a-zA-Z0-9_.\\-]+$";
|
|
4480
4607
|
var AI_MAX_DECLARED_TEMPLATES = 50;
|
|
@@ -4854,6 +4981,26 @@ var FunctionsConfigSchema = Type.Object({
|
|
|
4854
4981
|
{ enabled: Type.Boolean({ default: false }) },
|
|
4855
4982
|
{ default: {} }
|
|
4856
4983
|
),
|
|
4984
|
+
// FN-LOG-DRAIN (2026-10-12): forward this project's function log lines, in
|
|
4985
|
+
// signed batches, to the tenant's own https endpoint (functions-tail →
|
|
4986
|
+
// LogDrainDO). ONE optional leaf. The ONLY member of this deploy-managed
|
|
4987
|
+
// manifest a config PUT may change (handlers/functionsLogDrain.ts) — `vxil
|
|
4988
|
+
// push` carries it from `features.functions.logDrain`. `dailyLines` is
|
|
4989
|
+
// SERVER-SET from the plan at write time (LOG_DRAIN_LINES_PER_DAY_BY_TIER);
|
|
4990
|
+
// an authored value is overwritten. `secretRef` names a functions secret
|
|
4991
|
+
// (`vxil secrets set functions/<name>`), the HMAC key of the signature.
|
|
4992
|
+
logDrain: Type.Optional(Type.Object({
|
|
4993
|
+
url: Type.String({ minLength: 9, maxLength: 2048 }),
|
|
4994
|
+
secretRef: Type.String({ pattern: "^[a-z0-9_]{1,64}$" }),
|
|
4995
|
+
minLevel: Type.Optional(Type.Union([
|
|
4996
|
+
Type.Literal("debug"),
|
|
4997
|
+
Type.Literal("info"),
|
|
4998
|
+
Type.Literal("log"),
|
|
4999
|
+
Type.Literal("warn"),
|
|
5000
|
+
Type.Literal("error")
|
|
5001
|
+
])),
|
|
5002
|
+
dailyLines: Type.Optional(Type.Integer({ minimum: 0 }))
|
|
5003
|
+
})),
|
|
4857
5004
|
// ONE Type.Record leaf — the per-function object is DATA, not flags.
|
|
4858
5005
|
functions: Type.Optional(
|
|
4859
5006
|
Type.Record(
|
|
@@ -5103,6 +5250,138 @@ var FEATURE_KEYS = [
|
|
|
5103
5250
|
"functions",
|
|
5104
5251
|
"copilot"
|
|
5105
5252
|
];
|
|
5253
|
+
var CMS_RETAIN_DAYS_MAX = 3650;
|
|
5254
|
+
function isDeclaredRetainDays(v) {
|
|
5255
|
+
return v === void 0 || v === null || typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= CMS_RETAIN_DAYS_MAX;
|
|
5256
|
+
}
|
|
5257
|
+
function retainDaysShortens(declared, remote) {
|
|
5258
|
+
if (declared === null) return false;
|
|
5259
|
+
return remote === null || declared < remote;
|
|
5260
|
+
}
|
|
5261
|
+
|
|
5262
|
+
// ../config/src/index.ts
|
|
5263
|
+
var CMS_ROLE_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
5264
|
+
var CMS_ORG_READ_ROLES_MAX = 16;
|
|
5265
|
+
var ORG_FIELD_SLOTS = /* @__PURE__ */ new Set(["s1", "s2", "s3", "s4"]);
|
|
5266
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5267
|
+
function declaredOrgField(def) {
|
|
5268
|
+
if (!isObj(def)) return null;
|
|
5269
|
+
const v = def.orgField ?? def.org_field;
|
|
5270
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
5271
|
+
}
|
|
5272
|
+
function workspaceCollectionErrors(name, def) {
|
|
5273
|
+
if (!isObj(def)) return [];
|
|
5274
|
+
const errs = [];
|
|
5275
|
+
const at = `cms.collections.${name}`;
|
|
5276
|
+
const camel2 = def.orgField;
|
|
5277
|
+
const snake = def.org_field;
|
|
5278
|
+
if (camel2 !== void 0 && snake !== void 0 && camel2 !== snake) {
|
|
5279
|
+
errs.push(`${at} sets orgField and org_field to different values \u2014 send one`);
|
|
5280
|
+
}
|
|
5281
|
+
const rawOrg = camel2 ?? snake;
|
|
5282
|
+
let orgField = null;
|
|
5283
|
+
if (rawOrg !== void 0 && rawOrg !== null) {
|
|
5284
|
+
if (typeof rawOrg !== "string" || rawOrg.length === 0) {
|
|
5285
|
+
errs.push(`${at}.orgField must be a field name (got ${JSON.stringify(rawOrg)})`);
|
|
5286
|
+
} else {
|
|
5287
|
+
orgField = rawOrg;
|
|
5288
|
+
const f = isObj(def.fields) ? def.fields[rawOrg] : void 0;
|
|
5289
|
+
if (!isObj(f)) {
|
|
5290
|
+
errs.push(`${at}.orgField '${rawOrg}' names no declared field`);
|
|
5291
|
+
} else {
|
|
5292
|
+
if (f.type !== "string") {
|
|
5293
|
+
errs.push(`${at}.orgField '${rawOrg}' must be a string field (is ${JSON.stringify(f.type)})`);
|
|
5294
|
+
}
|
|
5295
|
+
const slot = f.indexSlot ?? f.index_slot;
|
|
5296
|
+
if (typeof slot !== "string" || !ORG_FIELD_SLOTS.has(slot)) {
|
|
5297
|
+
errs.push(`${at}.orgField '${rawOrg}' needs an indexSlot in s1\u2013s4 (every end-user read filters on it)`);
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
const owner = def.ownerField ?? def.owner_field;
|
|
5303
|
+
if (orgField !== null && owner === orgField) {
|
|
5304
|
+
errs.push(`${at}.orgField and ownerField must name different fields (both are '${orgField}')`);
|
|
5305
|
+
}
|
|
5306
|
+
const rrCamel = def.orgReadRoles;
|
|
5307
|
+
const rrSnake = def.org_read_roles;
|
|
5308
|
+
if (rrCamel !== void 0 && rrSnake !== void 0 && JSON.stringify(rrCamel) !== JSON.stringify(rrSnake)) {
|
|
5309
|
+
errs.push(`${at} sets orgReadRoles and org_read_roles to different values \u2014 send one`);
|
|
5310
|
+
}
|
|
5311
|
+
const rr = rrCamel ?? rrSnake;
|
|
5312
|
+
if (rr !== void 0 && rr !== null) {
|
|
5313
|
+
if (!Array.isArray(rr) || rr.some((r) => typeof r !== "string" || !CMS_ROLE_SLUG_RE.test(r))) {
|
|
5314
|
+
errs.push(`${at}.orgReadRoles must be an array of role slugs matching ${CMS_ROLE_SLUG_RE} (got ${JSON.stringify(rr)})`);
|
|
5315
|
+
} else if (rr.length > CMS_ORG_READ_ROLES_MAX) {
|
|
5316
|
+
errs.push(`${at}.orgReadRoles has ${rr.length} entries \u2014 at most ${CMS_ORG_READ_ROLES_MAX}`);
|
|
5317
|
+
} else if (rr.length > 0 && orgField === null) {
|
|
5318
|
+
errs.push(`${at}.orgReadRoles needs orgField (it gates reads of a workspace-scoped collection)`);
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
return errs;
|
|
5322
|
+
}
|
|
5323
|
+
var asList = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
|
|
5324
|
+
var stringsOf = (v) => asList(v).filter((s) => typeof s === "string");
|
|
5325
|
+
function workspaceManifestErrors(config, opts = {}) {
|
|
5326
|
+
const errs = [];
|
|
5327
|
+
const rawCollections = config.cms?.collections;
|
|
5328
|
+
const collections = isObj(rawCollections) ? rawCollections : {};
|
|
5329
|
+
const orgCols = /* @__PURE__ */ new Map();
|
|
5330
|
+
for (const [name, def] of Object.entries(collections)) {
|
|
5331
|
+
const of = declaredOrgField(def);
|
|
5332
|
+
if (of) orgCols.set(name, of);
|
|
5333
|
+
}
|
|
5334
|
+
if (orgCols.size === 0) return errs;
|
|
5335
|
+
const features = isObj(config.features) ? config.features : {};
|
|
5336
|
+
const names = [...orgCols.keys()].join(", ");
|
|
5337
|
+
const auth = isObj(features.auth) ? features.auth : {};
|
|
5338
|
+
const oc = isObj(auth.orgClaims) ? auth.orgClaims : {};
|
|
5339
|
+
if (opts.checkAuth !== false && (oc.enabled !== true || oc.activeOrg !== true)) {
|
|
5340
|
+
errs.push(`cms.collections ${names} set orgField, which needs features.auth.orgClaims { enabled: true, activeOrg: true } \u2014 without an active org the session carries no stable workspace`);
|
|
5341
|
+
}
|
|
5342
|
+
const cms = isObj(features.cms) ? features.cms : {};
|
|
5343
|
+
for (const [rule, r] of Object.entries(isObj(cms.cdc) ? cms.cdc : {})) {
|
|
5344
|
+
if (isObj(r) && typeof r.collection === "string" && orgCols.has(r.collection)) {
|
|
5345
|
+
errs.push(`features.cms.cdc.${rule}: collection '${r.collection}' is workspace-scoped (orgField) \u2014 a cdc channel is not access control, so it is refused until org channels ship (Phase 2); fan out from a function with per-workspace channel names meanwhile`);
|
|
5346
|
+
}
|
|
5347
|
+
}
|
|
5348
|
+
const vs = isObj(features["vector-search"]) ? features["vector-search"] : {};
|
|
5349
|
+
asList(vs.sync).forEach((s, i) => {
|
|
5350
|
+
if (isObj(s) && typeof s.cmsCollection === "string" && orgCols.has(s.cmsCollection)) {
|
|
5351
|
+
errs.push(`features.vector-search.sync[${i}]: cmsCollection '${s.cmsCollection}' is workspace-scoped (orgField) \u2014 workspace-scoped sync arrives with orgScoped (Phase 2); search through a function meanwhile`);
|
|
5352
|
+
}
|
|
5353
|
+
});
|
|
5354
|
+
for (const [rmName, rm] of Object.entries(isObj(cms.readModels) ? cms.readModels : {})) {
|
|
5355
|
+
if (!isObj(rm) || typeof rm.collection !== "string") continue;
|
|
5356
|
+
const srcOrg = orgCols.get(rm.collection);
|
|
5357
|
+
const to = isObj(rm.materialize) ? rm.materialize.to : void 0;
|
|
5358
|
+
if (!srcOrg || typeof to !== "string") continue;
|
|
5359
|
+
const tOrg = orgCols.get(to);
|
|
5360
|
+
const spec = isObj(rm.spec) ? rm.spec : {};
|
|
5361
|
+
const groups = [...stringsOf(spec.groupBy), ...stringsOf(spec.partitionBy)];
|
|
5362
|
+
if (tOrg !== srcOrg || !groups.includes(srcOrg)) {
|
|
5363
|
+
errs.push(`features.cms.readModels.${rmName}: materializes workspace-scoped '${rm.collection}' into '${to}' \u2014 the target must be an orgField collection with orgField '${srcOrg}' and the read model must group by '${srcOrg}', or one workspace's rollup rows land where every end user can read them`);
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
const orgs = isObj(features.orgs) ? features.orgs : {};
|
|
5367
|
+
const longRoles = new Set(asList(orgs.roles).map((r) => isObj(r) ? r.role_key : void 0).filter((k) => typeof k === "string" && k.length > 32));
|
|
5368
|
+
for (const [name] of orgCols) {
|
|
5369
|
+
const def = collections[name];
|
|
5370
|
+
const gate = /* @__PURE__ */ new Set([
|
|
5371
|
+
...stringsOf(def.orgReadRoles ?? def.org_read_roles),
|
|
5372
|
+
...stringsOf(def.writeRoles ?? def.write_roles)
|
|
5373
|
+
]);
|
|
5374
|
+
for (const f of Object.values(isObj(def.fields) ? def.fields : {})) {
|
|
5375
|
+
if (isObj(f)) for (const r of stringsOf(f.readRoles ?? f.read_roles)) gate.add(r);
|
|
5376
|
+
}
|
|
5377
|
+
for (const r of gate) {
|
|
5378
|
+
if (r.length > 32 || longRoles.has(r)) {
|
|
5379
|
+
errs.push(`cms.collections.${name}: gate role '${r}' is longer than 32 characters \u2014 an orgs role named in a cms gate must be 32 characters or fewer (longer keys stay legal in orgs)`);
|
|
5380
|
+
}
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5383
|
+
return errs;
|
|
5384
|
+
}
|
|
5106
5385
|
|
|
5107
5386
|
// src/lib.ts
|
|
5108
5387
|
var MAX_FN_BINDINGS = 8;
|
|
@@ -5169,9 +5448,9 @@ function lowerAllTriggerBindings(def, name) {
|
|
|
5169
5448
|
return bindings;
|
|
5170
5449
|
}
|
|
5171
5450
|
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
5172
|
-
var VXIL_CONFIG_PKG_VERSION = "0.
|
|
5173
|
-
var VXIL_SDK_PKG_VERSION = "0.
|
|
5174
|
-
var VXIL_CLI_PKG_VERSION = "0.
|
|
5451
|
+
var VXIL_CONFIG_PKG_VERSION = "0.18.0";
|
|
5452
|
+
var VXIL_SDK_PKG_VERSION = "0.25.0";
|
|
5453
|
+
var VXIL_CLI_PKG_VERSION = "0.25.0";
|
|
5175
5454
|
function ensureScaffoldPackageJson(cwd, opts = {}) {
|
|
5176
5455
|
const file = resolve(cwd, "package.json");
|
|
5177
5456
|
const wanted = {
|
|
@@ -5335,6 +5614,8 @@ async function loadVxilConfig(cwd = process.cwd()) {
|
|
|
5335
5614
|
throw new Error(`${found}: \`features\` must be an object`);
|
|
5336
5615
|
}
|
|
5337
5616
|
if (typeof cfg.apiVersion === "string") loadedConfigApiVersion = cfg.apiVersion;
|
|
5617
|
+
const wsErrs = workspaceManifestErrors(cfg);
|
|
5618
|
+
if (wsErrs.length) throw new Error(`${found}: ${wsErrs.join("; ")}`);
|
|
5338
5619
|
return cfg;
|
|
5339
5620
|
}
|
|
5340
5621
|
function diffManifest(prev, next, prefix = "") {
|
|
@@ -5353,6 +5634,19 @@ function diffManifest(prev, next, prefix = "") {
|
|
|
5353
5634
|
}
|
|
5354
5635
|
return out;
|
|
5355
5636
|
}
|
|
5637
|
+
var ACTIVE_ORG_MIN_SDK = "0.25.0";
|
|
5638
|
+
function activeOrgEnableWarning(feature, changes) {
|
|
5639
|
+
if (feature !== "auth") return null;
|
|
5640
|
+
const target = ["orgClaims", "activeOrg"];
|
|
5641
|
+
const at = (v, rest2) => rest2.reduce((o, k) => o && typeof o === "object" && !Array.isArray(o) ? o[k] : void 0, v);
|
|
5642
|
+
const turnsOn = changes.some((c) => {
|
|
5643
|
+
const segs = c.path === "" ? [] : c.path.split(".");
|
|
5644
|
+
if (segs.length > target.length || segs.some((k, i) => k !== target[i])) return false;
|
|
5645
|
+
const rest2 = target.slice(segs.length);
|
|
5646
|
+
return at(c.to, rest2) === true && at(c.from, rest2) !== true;
|
|
5647
|
+
});
|
|
5648
|
+
return turnsOn ? `auth orgClaims.activeOrg turns on: apps must use @vxil/sdk ${ACTIVE_ORG_MIN_SDK} or later \u2014 older SDKs do not recover from 401 session_stale after an org membership or role change (see guide 06, orgs)` : null;
|
|
5649
|
+
}
|
|
5356
5650
|
function formatDiff(changes) {
|
|
5357
5651
|
if (!changes.length) return " (no changes)";
|
|
5358
5652
|
return changes.map(({ path, from, to }) => {
|
|
@@ -5415,7 +5709,6 @@ async function mapPool(items, limit, fn) {
|
|
|
5415
5709
|
}
|
|
5416
5710
|
function assembleApplyBundle(cfg, fnSources) {
|
|
5417
5711
|
const { functions: _fnFeature, ...configFeatures } = cfg.features;
|
|
5418
|
-
void _fnFeature;
|
|
5419
5712
|
const functions = Object.entries(cfg.functions ?? {}).map(([name, def]) => {
|
|
5420
5713
|
const source = fnSources[name];
|
|
5421
5714
|
if (source === void 0) throw new Error(`functions: no bundled source for '${name}'`);
|
|
@@ -5434,8 +5727,11 @@ function assembleApplyBundle(cfg, fnSources) {
|
|
|
5434
5727
|
...limits ? { limits } : {}
|
|
5435
5728
|
};
|
|
5436
5729
|
});
|
|
5730
|
+
const fnFeature = _fnFeature;
|
|
5731
|
+
const features = { ...configFeatures };
|
|
5732
|
+
if (fnFeature && typeof fnFeature === "object") features.functions = { logDrain: fnFeature.logDrain ?? null };
|
|
5437
5733
|
return {
|
|
5438
|
-
...Object.keys(
|
|
5734
|
+
...Object.keys(features).length ? { features } : {},
|
|
5439
5735
|
...cfg.cms?.collections && Object.keys(cfg.cms.collections).length ? { cms: { collections: cfg.cms.collections } } : {},
|
|
5440
5736
|
...functions.length ? { functions } : {}
|
|
5441
5737
|
};
|
|
@@ -5593,6 +5889,40 @@ async function planOrPush({ api, features, apply, allowDestructive }) {
|
|
|
5593
5889
|
}
|
|
5594
5890
|
return results;
|
|
5595
5891
|
}
|
|
5892
|
+
function sameLogDrain(want, have) {
|
|
5893
|
+
const norm = (d) => {
|
|
5894
|
+
if (!d || typeof d !== "object") return null;
|
|
5895
|
+
const o = d;
|
|
5896
|
+
return JSON.stringify([o.url, String(o.secretRef ?? "").replace(/^secret:/, ""), o.minLevel ?? "warn"]);
|
|
5897
|
+
};
|
|
5898
|
+
return norm(want) === norm(have);
|
|
5899
|
+
}
|
|
5900
|
+
async function planOrPushLogDrain({ api, fnFeature, apply }) {
|
|
5901
|
+
if (!fnFeature || typeof fnFeature !== "object") return null;
|
|
5902
|
+
const to = fnFeature.logDrain ?? null;
|
|
5903
|
+
const cur = await api("GET", "/v1/config/functions");
|
|
5904
|
+
const unavailable = readUnavailable("GET /v1/config/functions", cur);
|
|
5905
|
+
if (unavailable) {
|
|
5906
|
+
if (apply) {
|
|
5907
|
+
throw new Error(`functions.logDrain: cannot read ${unavailable.route} (${unavailable.code ?? unavailable.status}) \u2014 refusing to push against a remote it could not read`);
|
|
5908
|
+
}
|
|
5909
|
+
return { version: 0, from: null, to, changed: false, applied: null, remoteUnavailable: unavailable };
|
|
5910
|
+
}
|
|
5911
|
+
const exists = cur.status === 200;
|
|
5912
|
+
const version = exists ? Number(cur.body.data?.version ?? 0) : 0;
|
|
5913
|
+
const from = exists ? cur.body.data?.manifest?.logDrain ?? null : null;
|
|
5914
|
+
const changed = !sameLogDrain(to, from);
|
|
5915
|
+
let applied = null;
|
|
5916
|
+
if (apply && changed) {
|
|
5917
|
+
const put = await api("PUT", "/v1/config/functions", { logDrain: to }, version > 0 ? { "if-match": String(version) } : {});
|
|
5918
|
+
if (put.status !== 200) {
|
|
5919
|
+
const e = put.body.error ?? {};
|
|
5920
|
+
throw new Error(`push failed for functions.logDrain: ${e.code ?? put.status} ${e.message ?? ""} ${e.hint ?? ""}`);
|
|
5921
|
+
}
|
|
5922
|
+
applied = put.body.data?.version ?? null;
|
|
5923
|
+
}
|
|
5924
|
+
return { version, from, to, changed, applied };
|
|
5925
|
+
}
|
|
5596
5926
|
function classifyRead(status, code) {
|
|
5597
5927
|
if (status === 200) return "ok";
|
|
5598
5928
|
if (status === 404) return "empty";
|
|
@@ -7951,18 +8281,18 @@ var PUBLIC_KEY_DENY_SCOPES = /* @__PURE__ */ new Set([
|
|
|
7951
8281
|
var TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
7952
8282
|
var FUNCTION_SOURCE_MAX_BYTES = 512e3;
|
|
7953
8283
|
var FN_PLATFORM_DEFAULT_CPU_MS = 3e4;
|
|
7954
|
-
function functionScriptCpuMs(declaredCpuMs, ceilingMs) {
|
|
8284
|
+
function functionScriptCpuMs(declaredCpuMs, ceilingMs, defaultMs = ceilingMs) {
|
|
7955
8285
|
const d = typeof declaredCpuMs === "number" && Number.isFinite(declaredCpuMs) && declaredCpuMs >= 1 ? Math.floor(declaredCpuMs) : null;
|
|
7956
|
-
return
|
|
8286
|
+
return Math.min(d ?? defaultMs, ceilingMs);
|
|
7957
8287
|
}
|
|
7958
8288
|
function recordedScriptCpuMs(raw) {
|
|
7959
8289
|
const c = raw?.cpuMs;
|
|
7960
8290
|
return isPositiveInt(c) ? c : FN_PLATFORM_DEFAULT_CPU_MS;
|
|
7961
8291
|
}
|
|
7962
|
-
function planScriptCpuMs(runtime, declaredCpuMs, ceilingMs) {
|
|
8292
|
+
function planScriptCpuMs(runtime, declaredCpuMs, ceilingMs, defaultMs = ceilingMs) {
|
|
7963
8293
|
const recorded = runtime?.cpuMs;
|
|
7964
8294
|
if (!isPositiveInt(recorded) && ceilingMs >= FN_PLATFORM_DEFAULT_CPU_MS) return FN_PLATFORM_DEFAULT_CPU_MS;
|
|
7965
|
-
return functionScriptCpuMs(declaredCpuMs, ceilingMs);
|
|
8295
|
+
return functionScriptCpuMs(declaredCpuMs, ceilingMs, defaultMs);
|
|
7966
8296
|
}
|
|
7967
8297
|
function isPositiveInt(v) {
|
|
7968
8298
|
return typeof v === "number" && Number.isInteger(v) && v > 0;
|
|
@@ -8126,6 +8456,35 @@ function egressDecision(targetUrl, allowHosts = []) {
|
|
|
8126
8456
|
return { allow: false, reason: `not-allowlisted:${host}` };
|
|
8127
8457
|
}
|
|
8128
8458
|
|
|
8459
|
+
// ../runtime/src/logDrain.ts
|
|
8460
|
+
var LOG_DRAIN_SECRET_NAME_RE = /^[a-z0-9_]{1,64}$/;
|
|
8461
|
+
var LOG_DRAIN_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "log", "warn", "error"]);
|
|
8462
|
+
var LOG_DRAIN_SHAPE_HINT = 'logDrain needs { url: "https://\u2026", secretRef: "<functions secret name>", minLevel?: "debug"|"info"|"log"|"warn"|"error" } (or null to remove it)';
|
|
8463
|
+
function logDrainUrlError(url) {
|
|
8464
|
+
let host;
|
|
8465
|
+
try {
|
|
8466
|
+
host = new URL(url).hostname;
|
|
8467
|
+
} catch {
|
|
8468
|
+
return "logDrain.url is not a URL";
|
|
8469
|
+
}
|
|
8470
|
+
const d = egressDecision(url, [host]);
|
|
8471
|
+
if (!d.allow) return `logDrain.url is refused by the egress guard (${d.reason})`;
|
|
8472
|
+
if (d.reason === "vxil-api") return "logDrain.url must be your own endpoint, not the vxil API";
|
|
8473
|
+
return null;
|
|
8474
|
+
}
|
|
8475
|
+
function logDrainShapeError(drain) {
|
|
8476
|
+
if (drain === null) return null;
|
|
8477
|
+
const d = drain;
|
|
8478
|
+
if (!d || typeof d !== "object" || Array.isArray(d) || typeof d.url !== "string" || typeof d.secretRef !== "string") {
|
|
8479
|
+
return LOG_DRAIN_SHAPE_HINT;
|
|
8480
|
+
}
|
|
8481
|
+
if (!LOG_DRAIN_SECRET_NAME_RE.test(d.secretRef.replace(/^secret:/, ""))) {
|
|
8482
|
+
return `logDrain.secretRef must be a functions secret name matching ${LOG_DRAIN_SECRET_NAME_RE.source}`;
|
|
8483
|
+
}
|
|
8484
|
+
if (d.minLevel !== void 0 && !LOG_DRAIN_LEVELS.has(d.minLevel)) return LOG_DRAIN_SHAPE_HINT;
|
|
8485
|
+
return logDrainUrlError(d.url);
|
|
8486
|
+
}
|
|
8487
|
+
|
|
8129
8488
|
// ../runtime/src/jobsCallbackSig.ts
|
|
8130
8489
|
var JOBS_SIG_HEADER = "X-Vxil-Jobs-Signature";
|
|
8131
8490
|
function signedString(runId, tenantId, issuedAt, bodyHashHex) {
|
|
@@ -8309,7 +8668,8 @@ var DRIFT_SECTION = {
|
|
|
8309
8668
|
policies: "policy",
|
|
8310
8669
|
subscriptions: "subscription",
|
|
8311
8670
|
templates: "template",
|
|
8312
|
-
roles: "role"
|
|
8671
|
+
roles: "role",
|
|
8672
|
+
collections: "search-collection"
|
|
8313
8673
|
};
|
|
8314
8674
|
function declaredDriftKey(datum, key) {
|
|
8315
8675
|
return `api:${DRIFT_SECTION[datum]}:${key}`;
|
|
@@ -8318,7 +8678,8 @@ var DATUM_READ_SCOPE = {
|
|
|
8318
8678
|
policies: "ratelimits:read",
|
|
8319
8679
|
subscriptions: "webhooks:read",
|
|
8320
8680
|
templates: "ai:read",
|
|
8321
|
-
roles: "orgs:read"
|
|
8681
|
+
roles: "orgs:read",
|
|
8682
|
+
collections: "vector-search:read"
|
|
8322
8683
|
};
|
|
8323
8684
|
function declaredApiStateBlocks(features) {
|
|
8324
8685
|
const out = [];
|
|
@@ -8357,7 +8718,8 @@ var ROUTE = {
|
|
|
8357
8718
|
policies: "/v1/rate-limits/policies",
|
|
8358
8719
|
subscriptions: "/v1/webhooks/subscriptions",
|
|
8359
8720
|
templates: "/v1/ai/templates",
|
|
8360
|
-
roles: "/v1/orgs/roles"
|
|
8721
|
+
roles: "/v1/orgs/roles",
|
|
8722
|
+
collections: "/v1/search/collections"
|
|
8361
8723
|
};
|
|
8362
8724
|
function listOf(v) {
|
|
8363
8725
|
return Array.isArray(v) ? v : [];
|
|
@@ -8422,6 +8784,14 @@ async function readOnlyPlan(api, block, allowDestructive) {
|
|
|
8422
8784
|
s.updated.push(...plan.update.map((u) => u.role.role_key));
|
|
8423
8785
|
s.unchanged.push(...plan.unchanged);
|
|
8424
8786
|
destructive([], plan.undeclared);
|
|
8787
|
+
} else if (datum === "collections") {
|
|
8788
|
+
const plan = planSearchCollections(manifest.collections, listOf(data2.collections));
|
|
8789
|
+
s.created.push(...plan.create.map((c) => c.collection));
|
|
8790
|
+
for (const u of plan.update) (u.reembed && !allowDestructive ? s.pending_destructive : s.updated).push(u.body.collection);
|
|
8791
|
+
s.unchanged.push(...plan.unchanged);
|
|
8792
|
+
s.undeclared.push(...plan.undeclared);
|
|
8793
|
+
for (const i of plan.immutable) s.failed.push({ key: i.collection, error: searchCollectionImmutableError(i) });
|
|
8794
|
+
for (const r of plan.refused) s.failed.push({ key: r.collection, error: r.error });
|
|
8425
8795
|
} else {
|
|
8426
8796
|
const hashed = await Promise.all(manifest.templates.map(async (t) => ({
|
|
8427
8797
|
template: t.template,
|
|
@@ -8823,7 +9193,9 @@ async function readRemote(api) {
|
|
|
8823
9193
|
const deployRuntime = wire ? recordedFunctionsRuntime(wire) : FUNCTIONS_RUNTIME_LEGACY;
|
|
8824
9194
|
const ceilingWire = res.status === 200 ? res.body.data?.cpuMsCeiling : void 0;
|
|
8825
9195
|
const cpuMsCeiling = typeof ceilingWire === "number" && Number.isInteger(ceilingWire) && ceilingWire > 0 ? ceilingWire : null;
|
|
8826
|
-
|
|
9196
|
+
const defaultWire = res.status === 200 ? res.body.data?.cpuMsDefault : void 0;
|
|
9197
|
+
const cpuMsDefault = typeof defaultWire === "number" && Number.isInteger(defaultWire) && defaultWire > 0 ? defaultWire : null;
|
|
9198
|
+
if (res.status !== 200) return { map: map2, unavailable, deployRuntime, cpuMsCeiling, cpuMsDefault };
|
|
8827
9199
|
const fns = Array.isArray(res.body.data?.functions) ? res.body.data.functions : [];
|
|
8828
9200
|
for (const f of fns) {
|
|
8829
9201
|
map2.set(f.name, {
|
|
@@ -8838,7 +9210,7 @@ async function readRemote(api) {
|
|
|
8838
9210
|
runtime: recordedFunctionsRuntime(f.runtime)
|
|
8839
9211
|
});
|
|
8840
9212
|
}
|
|
8841
|
-
return { map: map2, unavailable: null, deployRuntime, cpuMsCeiling };
|
|
9213
|
+
return { map: map2, unavailable: null, deployRuntime, cpuMsCeiling, cpuMsDefault };
|
|
8842
9214
|
}
|
|
8843
9215
|
function sameSecretSet(a, b2) {
|
|
8844
9216
|
return a.length === b2.length && a.every((x) => b2.includes(x));
|
|
@@ -8877,7 +9249,7 @@ async function planFunctions({ api, functions, cwd, apply, onNote, remoteOnly =
|
|
|
8877
9249
|
const sizeWarnings = headroom ? [{ fn: name, message: headroom }] : [];
|
|
8878
9250
|
const retryN = bindings.find((b2) => b2.retry)?.retry?.maxAttempts;
|
|
8879
9251
|
const label = `${name} (${Math.round(bytes / 100) / 10} KB) [${bindings.map((b2) => b2.kind).join("+")}${retryN !== void 0 ? " \xB7 retry:" + retryN : ""}${def.scopes?.length ? " \xB7 " + def.scopes.join(",") : ""}${secrets.length ? " \xB7 secrets:" + secrets.map((s) => s.slice("secret:".length)).join(",") : ""}${limits?.cpuMs !== void 0 ? " \xB7 cpu:" + limits.cpuMs + "ms" : ""}${limits?.timeoutMs !== void 0 ? " \xB7 egress:" + limits.timeoutMs + "ms" : ""}]`;
|
|
8880
|
-
if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime) && (read.cpuMsCeiling === null || recordedScriptCpuMs(remoteFn.runtime) === planScriptCpuMs(remoteFn.runtime, limits?.cpuMs, read.cpuMsCeiling)) && sameStringSet2(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet2(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null) && stableStringify(remoteFn.vars) === stableStringify(def.vars ?? {}) && !force) {
|
|
9252
|
+
if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${sha}`) && sameSecretSet(remoteFn.secrets, secrets) && sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime) && (read.cpuMsCeiling === null || recordedScriptCpuMs(remoteFn.runtime) === planScriptCpuMs(remoteFn.runtime, limits?.cpuMs, read.cpuMsCeiling, read.cpuMsDefault ?? read.cpuMsCeiling)) && sameStringSet2(clampFunctionScopes(def.scopes), remoteFn.scopes) && sameStringSet2(def.egressAllow ?? [], remoteFn.egressAllow) && stableStringify(remoteFn.bindings) === stableStringify(bindings) && stableStringify(remoteFn.signature) === stableStringify(def.signature ?? null) && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null) && stableStringify(remoteFn.vars) === stableStringify(def.vars ?? {}) && !force) {
|
|
8881
9253
|
return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null, warnings: sizeWarnings, hook: null };
|
|
8882
9254
|
}
|
|
8883
9255
|
const bindingChanges = remoteFn !== void 0 ? diffBindings(remoteFn.bindings, bindings) : null;
|
|
@@ -9053,6 +9425,12 @@ function writeRolesWidens(declared, remote) {
|
|
|
9053
9425
|
if (declared.length === 0) return true;
|
|
9054
9426
|
return declared.some((r) => !remote.includes(r));
|
|
9055
9427
|
}
|
|
9428
|
+
function orgFieldWidens(declared, remote) {
|
|
9429
|
+
return remote !== null && declared !== remote;
|
|
9430
|
+
}
|
|
9431
|
+
function orgReadRolesWidens(declared, remote) {
|
|
9432
|
+
return writeRolesWidens(declared, remote);
|
|
9433
|
+
}
|
|
9056
9434
|
function endUserAccessOf(v) {
|
|
9057
9435
|
return v === "read" || v === "none" ? v : "readwrite";
|
|
9058
9436
|
}
|
|
@@ -9149,6 +9527,9 @@ function remoteCollectionOf(c) {
|
|
|
9149
9527
|
public: c.public === true,
|
|
9150
9528
|
endUserAccess: endUserAccessOf(c.end_user_access),
|
|
9151
9529
|
writeRoles: writeRolesOf(c.write_roles),
|
|
9530
|
+
orgField: typeof c.org_field === "string" && c.org_field.length > 0 ? c.org_field : null,
|
|
9531
|
+
orgReadRoles: writeRolesOf(c.org_read_roles),
|
|
9532
|
+
retainDays: typeof c.retain_days === "number" ? c.retain_days : null,
|
|
9152
9533
|
actions: normalizeActions(c.actions),
|
|
9153
9534
|
fields: Array.isArray(c.fields) ? c.fields : []
|
|
9154
9535
|
};
|
|
@@ -9163,6 +9544,9 @@ function pulledCollectionBlock(c) {
|
|
|
9163
9544
|
...rc.public ? { public: true } : {},
|
|
9164
9545
|
...rc.endUserAccess !== "readwrite" ? { endUserAccess: rc.endUserAccess } : {},
|
|
9165
9546
|
...rc.writeRoles.length ? { writeRoles: rc.writeRoles } : {},
|
|
9547
|
+
...rc.orgField ? { orgField: rc.orgField } : {},
|
|
9548
|
+
...rc.orgReadRoles.length ? { orgReadRoles: rc.orgReadRoles } : {},
|
|
9549
|
+
...rc.retainDays !== null ? { retainDays: rc.retainDays } : {},
|
|
9166
9550
|
...rc.actions.length ? { actions: rc.actions } : {},
|
|
9167
9551
|
fields
|
|
9168
9552
|
};
|
|
@@ -9227,6 +9611,12 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
9227
9611
|
if (wr !== void 0 && (!Array.isArray(wr) || wr.some((r) => typeof r !== "string"))) {
|
|
9228
9612
|
throw new Error(`cms.collections.${name}.writeRoles must be an array of role slugs (got ${JSON.stringify(wr)})`);
|
|
9229
9613
|
}
|
|
9614
|
+
const wsErrs = workspaceCollectionErrors(name, def);
|
|
9615
|
+
if (wsErrs.length) throw new Error(wsErrs[0]);
|
|
9616
|
+
const rd = def.retainDays;
|
|
9617
|
+
if (!isDeclaredRetainDays(rd)) {
|
|
9618
|
+
throw new Error(`cms.collections.${name}.retainDays must be an integer 1..3650 (days) or null (got ${JSON.stringify(rd)})`);
|
|
9619
|
+
}
|
|
9230
9620
|
}
|
|
9231
9621
|
const read = await readRemote2(api);
|
|
9232
9622
|
if (read.unavailable) {
|
|
@@ -9407,6 +9797,63 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
9407
9797
|
});
|
|
9408
9798
|
}
|
|
9409
9799
|
}
|
|
9800
|
+
const declaredOrg = def.orgField ?? null;
|
|
9801
|
+
const declaredOrgRoles = writeRolesOf(def.orgReadRoles);
|
|
9802
|
+
const orgSteps = [];
|
|
9803
|
+
if (declaredOrg !== rc.orgField) {
|
|
9804
|
+
const widens = orgFieldWidens(declaredOrg, rc.orgField);
|
|
9805
|
+
if (!widens || allowDestructive) {
|
|
9806
|
+
orgSteps.push({
|
|
9807
|
+
collection: name,
|
|
9808
|
+
kind: "set-org-field",
|
|
9809
|
+
detail: `${name} orgField \u2192 ${declaredOrg === null ? "none" : `'${declaredOrg}'`}${rc.orgField ? ` (was '${rc.orgField}')` : ""}` + (widens ? " \u2014 WIDENS end-user access to workspace rows (full-config reconciliation)" : " (workspace scoping, narrows)")
|
|
9810
|
+
});
|
|
9811
|
+
} else {
|
|
9812
|
+
collChanges.push({
|
|
9813
|
+
collection: name,
|
|
9814
|
+
kind: "warn",
|
|
9815
|
+
detail: `${name}: orgField '${rc.orgField}' is set remotely but ${declaredOrg === null ? "absent" : `'${declaredOrg}'`} in config \u2014 that WIDENS which end users reach the rows, so it is NOT applied. Declare the remote orgField to keep it, or re-run with --allow-destructive.`
|
|
9816
|
+
});
|
|
9817
|
+
}
|
|
9818
|
+
}
|
|
9819
|
+
if (stableStringify(declaredOrgRoles) !== stableStringify(rc.orgReadRoles)) {
|
|
9820
|
+
const widens = orgReadRolesWidens(declaredOrgRoles, rc.orgReadRoles);
|
|
9821
|
+
const fmt = (r) => r.length ? `[${r.join(", ")}]` : "none (any member)";
|
|
9822
|
+
if (!widens || allowDestructive) {
|
|
9823
|
+
const step = {
|
|
9824
|
+
collection: name,
|
|
9825
|
+
kind: "set-org-read-roles",
|
|
9826
|
+
detail: `${name} orgReadRoles \u2192 ${fmt(declaredOrgRoles)} (was ${fmt(rc.orgReadRoles)})` + (widens ? " \u2014 WIDENS who may read (full-config reconciliation)" : " (narrows end-user reads)")
|
|
9827
|
+
};
|
|
9828
|
+
if (declaredOrg === null) orgSteps.unshift(step);
|
|
9829
|
+
else orgSteps.push(step);
|
|
9830
|
+
} else {
|
|
9831
|
+
collChanges.push({
|
|
9832
|
+
collection: name,
|
|
9833
|
+
kind: "warn",
|
|
9834
|
+
detail: `${name}: orgReadRoles are ${fmt(rc.orgReadRoles)} remotely but ${fmt(declaredOrgRoles)} in config \u2014 that WIDENS which workspace members may read, so it is NOT applied. Declare the remote roles to keep them, or re-run with --allow-destructive to widen.`
|
|
9835
|
+
});
|
|
9836
|
+
}
|
|
9837
|
+
}
|
|
9838
|
+
collChanges.push(...orgSteps);
|
|
9839
|
+
const declaredRetain = def.retainDays;
|
|
9840
|
+
if (declaredRetain !== void 0 && declaredRetain !== rc.retainDays) {
|
|
9841
|
+
const fmtDays = (d) => d === null ? "off (kept until deleted)" : `${d} days`;
|
|
9842
|
+
const shortens = retainDaysShortens(declaredRetain, rc.retainDays);
|
|
9843
|
+
if (!shortens || allowDestructive) {
|
|
9844
|
+
collChanges.push({
|
|
9845
|
+
collection: name,
|
|
9846
|
+
kind: "set-retain-days",
|
|
9847
|
+
detail: `${name} retainDays \u2192 ${fmtDays(declaredRetain)} (was ${fmtDays(rc.retainDays)})` + (shortens ? " \u2014 the nightly sweep soft-deletes items older than that" : "")
|
|
9848
|
+
});
|
|
9849
|
+
} else {
|
|
9850
|
+
collChanges.push({
|
|
9851
|
+
collection: name,
|
|
9852
|
+
kind: "warn",
|
|
9853
|
+
detail: `${name}: retainDays is ${fmtDays(rc.retainDays)} remotely but ${fmtDays(declaredRetain)} in config \u2014 that makes the nightly sweep soft-delete existing items, so it is NOT applied. Re-run with --allow-destructive to apply it.`
|
|
9854
|
+
});
|
|
9855
|
+
}
|
|
9856
|
+
}
|
|
9410
9857
|
const declaredActions = normalizeActions(def.actions);
|
|
9411
9858
|
if (stableStringify(declaredActions) !== stableStringify(rc.actions)) {
|
|
9412
9859
|
collChanges.push({
|
|
@@ -9482,6 +9929,10 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
9482
9929
|
if (access !== "readwrite") body.end_user_access = access;
|
|
9483
9930
|
const roles = writeRolesOf(def.writeRoles);
|
|
9484
9931
|
if (roles.length > 0) body.write_roles = roles;
|
|
9932
|
+
if (def.orgField) body.org_field = def.orgField;
|
|
9933
|
+
const orgRoles = writeRolesOf(def.orgReadRoles);
|
|
9934
|
+
if (orgRoles.length > 0) body.org_read_roles = orgRoles;
|
|
9935
|
+
if (typeof def.retainDays === "number") body.retain_days = def.retainDays;
|
|
9485
9936
|
if (def.actions && def.actions.length > 0) body.actions = normalizeActions(def.actions);
|
|
9486
9937
|
const res = await api("POST", "/v1/cms/collections", body);
|
|
9487
9938
|
if (res.status !== 201 && res.status !== 200 && res.body.error?.code !== "collection_exists") {
|
|
@@ -9541,6 +9992,28 @@ async function planCmsSchema({ api, collections, apply, allowDestructive = false
|
|
|
9541
9992
|
throw new Error(`cms: set writeRoles on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
9542
9993
|
}
|
|
9543
9994
|
applied++;
|
|
9995
|
+
} else if (c.kind === "set-org-field" || c.kind === "set-org-read-roles") {
|
|
9996
|
+
const def = collections[c.collection];
|
|
9997
|
+
const remoteRc = remote.get(c.collection);
|
|
9998
|
+
const isField = c.kind === "set-org-field";
|
|
9999
|
+
const target = isField ? def.orgField ?? null : writeRolesOf(def.orgReadRoles);
|
|
10000
|
+
const widens = isField ? orgFieldWidens(target, remoteRc?.orgField ?? null) : orgReadRolesWidens(target, remoteRc?.orgReadRoles ?? []);
|
|
10001
|
+
const body = isField ? { org_field: target } : { org_read_roles: target };
|
|
10002
|
+
if (widens) body.allow_destructive = true;
|
|
10003
|
+
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, body);
|
|
10004
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
10005
|
+
const e = res.body.error ?? {};
|
|
10006
|
+
throw new Error(`cms: set ${isField ? "orgField" : "orgReadRoles"} on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
10007
|
+
}
|
|
10008
|
+
applied++;
|
|
10009
|
+
} else if (c.kind === "set-retain-days") {
|
|
10010
|
+
const target = collections[c.collection].retainDays ?? null;
|
|
10011
|
+
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { retain_days: target });
|
|
10012
|
+
if (res.status !== 200 && res.status !== 201) {
|
|
10013
|
+
const e = res.body.error ?? {};
|
|
10014
|
+
throw new Error(`cms: set retainDays on ${c.collection} failed: ${e.code ?? res.status} ${e.message ?? ""}`);
|
|
10015
|
+
}
|
|
10016
|
+
applied++;
|
|
9544
10017
|
} else if (c.kind === "set-actions") {
|
|
9545
10018
|
const target = normalizeActions(collections[c.collection].actions);
|
|
9546
10019
|
const res = await api("POST", `/v1/cms/collections/${encodeURIComponent(c.collection)}/fields`, { actions: target });
|
|
@@ -9606,7 +10079,7 @@ function formatCmsChanges(changes) {
|
|
|
9606
10079
|
if (c.kind === "destructive") return ` ! ${c.detail}`;
|
|
9607
10080
|
if (c.kind === "warn") return ` \u26A0 ${c.detail}`;
|
|
9608
10081
|
if (c.kind === "reindex") return ` \u21BB ${c.detail}`;
|
|
9609
|
-
if (c.kind === "alter-flags" || c.kind === "set-owner-field" || c.kind === "set-end-user-access" || c.kind === "set-write-roles") return ` ~ ${c.detail}`;
|
|
10082
|
+
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}`;
|
|
9610
10083
|
return ` + ${c.detail}`;
|
|
9611
10084
|
}).join("\n");
|
|
9612
10085
|
}
|
|
@@ -12501,7 +12974,11 @@ var TOOLS = [
|
|
|
12501
12974
|
description: "Explicit locale wins; otherwise the recipient's stored `locale` attribute (users_upsert attributes.locale), then the tenant's config.defaultLocale."
|
|
12502
12975
|
},
|
|
12503
12976
|
send_at: { type: "string", description: "future ISO instant, \u2264 30 d out (mutually exclusive with delay_seconds)" },
|
|
12504
|
-
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 }
|
|
12977
|
+
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 },
|
|
12978
|
+
to_email: { type: "string", description: "Deliver to this address instead of the user's stored email (an invitee with no account). user_id must still exist; suppressions are checked against this address." },
|
|
12979
|
+
from_name: { type: "string", maxLength: 64, description: "This send's sender name; default config fromName." },
|
|
12980
|
+
from_local_part: { type: "string", pattern: "^[a-z0-9._-]{1,40}$", description: "This send's from-address local part; the domain is always the config fromEmail domain." },
|
|
12981
|
+
reply_to: { type: "string", description: "This send's Reply-To address; default config replyTo." }
|
|
12505
12982
|
},
|
|
12506
12983
|
required: ["user_id", "template", "data"]
|
|
12507
12984
|
},
|
|
@@ -12528,6 +13005,10 @@ var TOOLS = [
|
|
|
12528
13005
|
channel: { type: "string", enum: ["email", "inbox", "both"] },
|
|
12529
13006
|
send_at: { type: "string", description: "future ISO instant (\u2264 30 d)" },
|
|
12530
13007
|
delay_seconds: { type: "number", minimum: 0, maximum: 2592e3 },
|
|
13008
|
+
to_email: { type: "string" },
|
|
13009
|
+
from_name: { type: "string", maxLength: 64 },
|
|
13010
|
+
from_local_part: { type: "string", pattern: "^[a-z0-9._-]{1,40}$" },
|
|
13011
|
+
reply_to: { type: "string" },
|
|
12531
13012
|
idempotency_key: { type: "string" }
|
|
12532
13013
|
},
|
|
12533
13014
|
required: ["user_id", "template", "data"]
|
|
@@ -13210,14 +13691,19 @@ var TOOLS = [
|
|
|
13210
13691
|
{
|
|
13211
13692
|
name: "files_get_download_url",
|
|
13212
13693
|
feature: "files",
|
|
13213
|
-
description:
|
|
13694
|
+
description: 'Mint a short-lived presigned GET URL for an available object. disposition "inline" opens a PDF/image/ICO/audio/video in the browser (html, svg, js and other types always download); filename sets the saved name.',
|
|
13214
13695
|
inputSchema: {
|
|
13215
13696
|
type: "object",
|
|
13216
|
-
properties: {
|
|
13697
|
+
properties: {
|
|
13698
|
+
object_id: { type: "string" },
|
|
13699
|
+
disposition: { type: "string", enum: ["inline", "attachment"], description: "Omit for the default (raster images inline, everything else attachment)." },
|
|
13700
|
+
filename: { type: "string", description: "Download filename (sanitised server-side, at most 200 characters). Default: the upload filename." }
|
|
13701
|
+
},
|
|
13217
13702
|
required: ["object_id"]
|
|
13218
13703
|
},
|
|
13219
13704
|
method: "GET",
|
|
13220
|
-
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/download-url
|
|
13705
|
+
path: (a) => `/v1/files/${encodeURIComponent(String(a.object_id))}/download-url`,
|
|
13706
|
+
queryArgs: ["disposition", "filename"]
|
|
13221
13707
|
},
|
|
13222
13708
|
{
|
|
13223
13709
|
name: "files_get_download_urls",
|
|
@@ -13353,7 +13839,7 @@ var TOOLS = [
|
|
|
13353
13839
|
{
|
|
13354
13840
|
name: "cms_list_collections",
|
|
13355
13841
|
feature: "cms",
|
|
13356
|
-
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.",
|
|
13842
|
+
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.",
|
|
13357
13843
|
inputSchema: { type: "object", properties: {} },
|
|
13358
13844
|
method: "GET",
|
|
13359
13845
|
path: "/v1/cms/collections"
|
|
@@ -13701,15 +14187,18 @@ var TOOLS = [
|
|
|
13701
14187
|
{
|
|
13702
14188
|
name: "orgs_session_claims",
|
|
13703
14189
|
feature: "orgs",
|
|
13704
|
-
description: "The
|
|
14190
|
+
description: "The org snapshot \u2014 { org_id, role, perms[] } \u2014 auth embeds as the session-JWT `org` claim when the tenant enables auth config orgClaims. Without org_id it resolves the user's MOST RECENT membership (by joined_at): the claim a session carries with plain orgClaims, and the one a first sign-in stores with orgClaims.activeOrg. With orgClaims.activeOrg a session then acts in the org it stored or switched to (POST /v1/auth/sessions/refresh with org_id), not necessarily the most recent one \u2014 pass org_id to read that org's claim. perms = the role's effective permission set (custom catalog entry wins, capped at 32). Not a member (or no membership) \u2192 org_id null. EVENTUAL-CONSISTENT: JWTs recompute it only at mint/refresh \u2014 use orgs_check_permission for revocation-grade authz. Read-only \u2014 needs orgs:read.",
|
|
13705
14191
|
inputSchema: {
|
|
13706
14192
|
type: "object",
|
|
13707
|
-
properties: {
|
|
14193
|
+
properties: {
|
|
14194
|
+
user_id: { type: "string", description: "The end_user_id to resolve." },
|
|
14195
|
+
org_id: { type: "string", description: "Optional: resolve the claim for THIS org (the active org a session picked) instead of the most recent membership." }
|
|
14196
|
+
},
|
|
13708
14197
|
required: ["user_id"]
|
|
13709
14198
|
},
|
|
13710
14199
|
method: "GET",
|
|
13711
14200
|
path: "/v1/orgs/session-claims",
|
|
13712
|
-
queryArgs: ["user_id"]
|
|
14201
|
+
queryArgs: ["user_id", "org_id"]
|
|
13713
14202
|
},
|
|
13714
14203
|
{
|
|
13715
14204
|
name: "orgs_define_role",
|
|
@@ -13868,7 +14357,7 @@ var TOOLS = [
|
|
|
13868
14357
|
{
|
|
13869
14358
|
name: "functions_list",
|
|
13870
14359
|
feature: "functions",
|
|
13871
|
-
description: "The project's deployed functions: name, endpoint (/v1/fn/<name>), trigger bindings, scopes, limits and the runtime each runs under, plus `enabled` (false while the project's functions are paused as a whole \u2014 invokes then answer 404) and `
|
|
14360
|
+
description: "The project's deployed functions: name, endpoint (/v1/fn/<name>), trigger bindings, scopes, limits and the runtime each runs under, plus `enabled` (false while the project's functions are paused as a whole \u2014 invokes then answer 404), `cpuMsCeiling` and `cpuMsDefault` (the CPU a function declaring no limits.cpuMs gets). Each entry carries its plain `vars` only for a server key holding features:read or features:write (never for an end-user request). Read-only; any authenticated key may read it. Use it before functions_invoke or functions_logs to get the exact names.",
|
|
13872
14361
|
inputSchema: { type: "object", properties: {} },
|
|
13873
14362
|
method: "GET",
|
|
13874
14363
|
path: "/v1/functions"
|
|
@@ -13892,7 +14381,7 @@ var TOOLS = [
|
|
|
13892
14381
|
{
|
|
13893
14382
|
name: "functions_logs",
|
|
13894
14383
|
feature: "functions",
|
|
13895
|
-
description:
|
|
14384
|
+
description: 'Log records of ONE deployed function, one per invocation (ts, outcome, wall_ms, cpu_ms, console lines, exceptions), oldest first, with `next_since` \u2014 pass it back as `since` to read only newer records (a tail). Without `since`: the most recent records of the last 24 hours. Kept 14 days. Needs functions:read. To forward log lines to your own https endpoint (kept as long as you like), declare features.functions.logDrain { url, secretRef, minLevel } and push it, or PUT /v1/config/functions with { "logDrain": {\u2026} } (null removes it); this tool only reads.',
|
|
13896
14385
|
inputSchema: {
|
|
13897
14386
|
type: "object",
|
|
13898
14387
|
properties: {
|
|
@@ -14063,7 +14552,7 @@ var TOOLS = [
|
|
|
14063
14552
|
{
|
|
14064
14553
|
name: "search_query",
|
|
14065
14554
|
feature: "vector-search",
|
|
14066
|
-
description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object
|
|
14555
|
+
description: "Retrieve the top-k most relevant chunks from a vector-search collection. mode 'hybrid' (default) fuses semantic + keyword via RRF; 'vector' is pure semantic; 'keyword' is lexical. filter is a JSON object over top-level chunk metadata keys, ANDed: {key: value} is equality, {key: {$in: [a, b]}} matches any of 1\u201350 values (\u22644 $in keys); any other value is refused 422. Returns ranked results with document/chunk attribution: score is the fused rank value (orders the hits, not a similarity); similarity is the cosine similarity to the query (-1..1, null in keyword mode) \u2014 threshold on similarity. embed_provider names the collection's embedder; 'mock' is the deterministic test embedder (ranks by a hash, not meaning), so tell the user search is not semantic until a real provider is set.",
|
|
14067
14556
|
inputSchema: {
|
|
14068
14557
|
type: "object",
|
|
14069
14558
|
properties: {
|
|
@@ -14071,7 +14560,7 @@ var TOOLS = [
|
|
|
14071
14560
|
query: { type: "string", description: "Natural-language query text." },
|
|
14072
14561
|
top_k: { type: "number", minimum: 1, maximum: 100, default: 10 },
|
|
14073
14562
|
mode: { type: "string", enum: ["hybrid", "vector", "keyword"], default: "hybrid" },
|
|
14074
|
-
filter: { type: "object", additionalProperties: true, description: 'Metadata
|
|
14563
|
+
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.' },
|
|
14075
14564
|
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." }
|
|
14076
14565
|
},
|
|
14077
14566
|
required: ["collection", "query"]
|
|
@@ -14237,7 +14726,7 @@ var TOOLS = [
|
|
|
14237
14726
|
template: { type: "string", description: "Override the answer template (defaults to rag config defaultTemplate)." },
|
|
14238
14727
|
top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve before budgeting." },
|
|
14239
14728
|
mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
|
|
14240
|
-
filter: { type: "object", additionalProperties: true, description:
|
|
14729
|
+
filter: { type: "object", additionalProperties: true, description: 'Metadata filter applied to retrieval: equality {"key":"value"} or any-of {"key":{"$in":[...]}} (1\u201350 values), keys ANDed.' },
|
|
14241
14730
|
provider: { type: "string" },
|
|
14242
14731
|
model: { type: "string" },
|
|
14243
14732
|
input: { type: "object", additionalProperties: true, description: "Extra template vars beyond {{query}}/{{context}}." },
|
|
@@ -14260,7 +14749,7 @@ var TOOLS = [
|
|
|
14260
14749
|
collection: { type: "string", description: "Collection to search (defaults to rag config defaultCollection)." },
|
|
14261
14750
|
top_k: { type: "number", minimum: 1, maximum: 100, description: "Chunks to retrieve." },
|
|
14262
14751
|
mode: { type: "string", enum: ["hybrid", "vector", "keyword"] },
|
|
14263
|
-
filter: { type: "object", additionalProperties: true, description:
|
|
14752
|
+
filter: { type: "object", additionalProperties: true, description: 'Metadata filter applied to retrieval: equality {"key":"value"} or any-of {"key":{"$in":[...]}} (1\u201350 values), keys ANDed.' },
|
|
14264
14753
|
rerank: { type: "boolean", description: "Forward rerank:true to vector-search's BYO rerank pass (inert until configured there)." },
|
|
14265
14754
|
boosts: { type: "object", additionalProperties: true, description: "Per-metadata-field boost specs: { field: { kind:'recency', halfLifeDays?, weight? } | { kind:'value', weights:{\u2026} } }." },
|
|
14266
14755
|
min_score: { type: "number", description: "Drop hits below this EFFECTIVE (post-boost) score. score is a rank value (about 0.016-0.033 at the top), so a floor above ~0.033 drops every hit; use min_similarity for a relevance threshold." },
|
|
@@ -14846,6 +15335,10 @@ function collectSecretRefs(cfg) {
|
|
|
14846
15335
|
if (m) add({ name: m[1], feature, via: `features.${feature}.${path}` });
|
|
14847
15336
|
}
|
|
14848
15337
|
}
|
|
15338
|
+
const drainRef = cfg.features?.functions?.logDrain?.secretRef;
|
|
15339
|
+
if (typeof drainRef === "string" && drainRef.replace(/^secret:/, "")) {
|
|
15340
|
+
add({ name: drainRef.replace(/^secret:/, ""), feature: "functions", via: "features.functions.logDrain.secretRef" });
|
|
15341
|
+
}
|
|
14849
15342
|
for (const [fn, def] of Object.entries(cfg.functions ?? {})) {
|
|
14850
15343
|
for (const ref of normalizeSecretRefs(def.secrets)) {
|
|
14851
15344
|
const bare = ref.slice("secret:".length);
|
|
@@ -14921,7 +15414,8 @@ var COMPARED_LABELS = [
|
|
|
14921
15414
|
["subscriptions", "subscription"],
|
|
14922
15415
|
["campaigns", "campaign"],
|
|
14923
15416
|
["templates", "template"],
|
|
14924
|
-
["roles", "role"]
|
|
15417
|
+
["roles", "role"],
|
|
15418
|
+
["searchCollections", "search collection"]
|
|
14925
15419
|
];
|
|
14926
15420
|
function formatCompared(c) {
|
|
14927
15421
|
const parts = [];
|
|
@@ -15879,8 +16373,12 @@ function renderTryReport(result, opts) {
|
|
|
15879
16373
|
|
|
15880
16374
|
// src/brief.ts
|
|
15881
16375
|
var SITE = "https://vxil.com";
|
|
15882
|
-
|
|
15883
|
-
|
|
16376
|
+
var STAGING_SITE = "https://staging.vxil.org";
|
|
16377
|
+
function briefSiteFor(edgeBase) {
|
|
16378
|
+
return envLabelFor(edgeBase) === "production" ? SITE : STAGING_SITE;
|
|
16379
|
+
}
|
|
16380
|
+
function briefDocsUrl(docs, site = SITE) {
|
|
16381
|
+
return docs.startsWith("/") ? `${site}${docs}` : docs;
|
|
15884
16382
|
}
|
|
15885
16383
|
function renderBriefLines(brief, opts = {}) {
|
|
15886
16384
|
if (!brief) {
|
|
@@ -15903,7 +16401,7 @@ function renderBriefLines(brief, opts = {}) {
|
|
|
15903
16401
|
}
|
|
15904
16402
|
return out;
|
|
15905
16403
|
}
|
|
15906
|
-
function renderBriefMarkdown(brief, app = {}) {
|
|
16404
|
+
function renderBriefMarkdown(brief, app = {}, opts = {}) {
|
|
15907
16405
|
const name = (app.name ?? "").trim() || "Your app";
|
|
15908
16406
|
if (!brief) {
|
|
15909
16407
|
return [
|
|
@@ -15922,7 +16420,7 @@ function renderBriefMarkdown(brief, app = {}) {
|
|
|
15922
16420
|
for (const s of brief.sections) {
|
|
15923
16421
|
lines.push(`## ${s.title}`, "");
|
|
15924
16422
|
for (const i of s.items) {
|
|
15925
|
-
lines.push(`- **[${i.title}](${briefDocsUrl(i.docs)})** \u2014 ${i.fact}`);
|
|
16423
|
+
lines.push(`- **[${i.title}](${briefDocsUrl(i.docs, opts.site)})** \u2014 ${i.fact}`);
|
|
15926
16424
|
if (i.why) lines.push(` _why:_ ${i.why}`);
|
|
15927
16425
|
if (i.replaces) lines.push(` _instead of:_ ${i.replaces}`);
|
|
15928
16426
|
}
|
|
@@ -16095,7 +16593,9 @@ var EXTRA_KNOWN_FLAGS = [
|
|
|
16095
16593
|
"include-empty",
|
|
16096
16594
|
"include-password-hashes",
|
|
16097
16595
|
"with-password-hashes",
|
|
16596
|
+
"with-oauth-identities",
|
|
16098
16597
|
"keep-source-id",
|
|
16598
|
+
"keep-timestamps",
|
|
16099
16599
|
"oidc-issuer",
|
|
16100
16600
|
"only",
|
|
16101
16601
|
"pg-schema",
|
|
@@ -16798,7 +17298,8 @@ function inferMapping(source, opts = {}) {
|
|
|
16798
17298
|
if (owner) mapping.userRefColumn = owner.column;
|
|
16799
17299
|
for (const col of t.columns) {
|
|
16800
17300
|
if (col.name === "created_at" || col.name === "updated_at") {
|
|
16801
|
-
mapping.reasons.push(`R13: ${col.name} dropped \u2014 cms manages created_at/updated_at (original values
|
|
17301
|
+
mapping.reasons.push(`R13: ${col.name} dropped \u2014 cms manages created_at/updated_at (original values kept with \`data --keep-timestamps\`)`);
|
|
17302
|
+
mapping.sourceTimestamps = { ...mapping.sourceTimestamps, [col.name === "created_at" ? "created" : "updated"]: col.name };
|
|
16802
17303
|
continue;
|
|
16803
17304
|
}
|
|
16804
17305
|
const isOwnerCol = owner?.column === col.name;
|
|
@@ -17200,6 +17701,32 @@ function storagePatch(row2, fields, resolve8) {
|
|
|
17200
17701
|
function readColumns(mapping, pk, extra = []) {
|
|
17201
17702
|
return [.../* @__PURE__ */ new Set([pk, ...mapping.fields.map((f) => f.source), ...mapping.changeColumn ? [mapping.changeColumn] : [], ...extra])];
|
|
17202
17703
|
}
|
|
17704
|
+
function sourceTimestampColumns(mapping) {
|
|
17705
|
+
if (mapping.sourceTimestamps) return { ...mapping.sourceTimestamps };
|
|
17706
|
+
const out = {};
|
|
17707
|
+
for (const r of mapping.reasons) {
|
|
17708
|
+
const m = /^R13: (created_at|updated_at) dropped/.exec(r);
|
|
17709
|
+
if (m?.[1] === "created_at") out.created = "created_at";
|
|
17710
|
+
if (m?.[1] === "updated_at") out.updated = "updated_at";
|
|
17711
|
+
}
|
|
17712
|
+
return out;
|
|
17713
|
+
}
|
|
17714
|
+
var TS_MIN_MS = 0;
|
|
17715
|
+
var TS_FUTURE_MARGIN_MS = 6e4;
|
|
17716
|
+
var tsIso = (raw, nowMs) => {
|
|
17717
|
+
let t;
|
|
17718
|
+
if (raw instanceof Date) t = raw.getTime();
|
|
17719
|
+
else if (typeof raw === "string" && raw !== "") t = Date.parse(raw);
|
|
17720
|
+
else return null;
|
|
17721
|
+
if (Number.isNaN(t) || t < TS_MIN_MS || t > nowMs + TS_FUTURE_MARGIN_MS) return null;
|
|
17722
|
+
return new Date(t).toISOString();
|
|
17723
|
+
};
|
|
17724
|
+
function sourceTimestampsOf(row2, cols, nowMs = Date.now()) {
|
|
17725
|
+
const created = cols.created ? tsIso(row2[cols.created], nowMs) : null;
|
|
17726
|
+
if (created === null) return void 0;
|
|
17727
|
+
const updated = cols.updated ? tsIso(row2[cols.updated], nowMs) : null;
|
|
17728
|
+
return updated !== null && updated >= created ? { created_at: created, updated_at: updated } : { created_at: created };
|
|
17729
|
+
}
|
|
17203
17730
|
var SCALAR_UNIQUE = /* @__PURE__ */ new Set(["string", "int", "float", "datetime"]);
|
|
17204
17731
|
function buildItem(row2, mapping, state, tableByCollection, resolve8) {
|
|
17205
17732
|
const full = {};
|
|
@@ -17256,7 +17783,18 @@ function holdsContent(stored, item, storageTargets) {
|
|
|
17256
17783
|
return cur === rowHash(v) || storageTargets.has(k) && cur === rowHash(item.hashInput[k]);
|
|
17257
17784
|
});
|
|
17258
17785
|
}
|
|
17259
|
-
async function loadCmsTable({
|
|
17786
|
+
async function loadCmsTable({
|
|
17787
|
+
deps,
|
|
17788
|
+
plan,
|
|
17789
|
+
mapping,
|
|
17790
|
+
adapter,
|
|
17791
|
+
state,
|
|
17792
|
+
save,
|
|
17793
|
+
pkColumn,
|
|
17794
|
+
itemStatus = "published",
|
|
17795
|
+
delta,
|
|
17796
|
+
keepTimestamps = false
|
|
17797
|
+
}) {
|
|
17260
17798
|
const table = mapping.table;
|
|
17261
17799
|
const collection = mapping.collection;
|
|
17262
17800
|
const key = `cms:${table}`;
|
|
@@ -17290,8 +17828,12 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
17290
17828
|
let unhashedSame = 0;
|
|
17291
17829
|
const enc2 = encodeURIComponent(collection);
|
|
17292
17830
|
const start = cursors[key] ?? void 0;
|
|
17831
|
+
const tsCols = keepTimestamps ? sourceTimestampColumns(mapping) : {};
|
|
17832
|
+
if (keepTimestamps && tsCols.created === void 0) {
|
|
17833
|
+
deps.log(`cms ${table}: --keep-timestamps \u2014 the source has no created_at column; items take the time of the import`);
|
|
17834
|
+
}
|
|
17293
17835
|
const readOpts = {
|
|
17294
|
-
columns: readColumns(mapping, pk),
|
|
17836
|
+
columns: readColumns(mapping, pk, [tsCols.created, tsCols.updated].filter((c) => c !== void 0)),
|
|
17295
17837
|
...delta && mapping.changeColumn ? { changedSince: { column: mapping.changeColumn, since: delta.since } } : {}
|
|
17296
17838
|
};
|
|
17297
17839
|
const noteStorage = (sourceId, row2) => {
|
|
@@ -17374,7 +17916,12 @@ async function loadCmsTable({ deps, plan, mapping, adapter, state, save, pkColum
|
|
|
17374
17916
|
continue;
|
|
17375
17917
|
}
|
|
17376
17918
|
if (deps.dryRun) continue;
|
|
17377
|
-
const
|
|
17919
|
+
const srcTs = keepTimestamps ? sourceTimestampsOf(row2, tsCols) : void 0;
|
|
17920
|
+
const res = await deps.api("POST", `/v1/cms/items/${enc2}`, {
|
|
17921
|
+
data: item.create,
|
|
17922
|
+
status: itemStatus,
|
|
17923
|
+
...srcTs ? { source_timestamps: srcTs } : {}
|
|
17924
|
+
});
|
|
17378
17925
|
if (res.status === 201 || res.status === 200) {
|
|
17379
17926
|
const itemId = res.body.data?.item_id;
|
|
17380
17927
|
if (typeof itemId !== "string") {
|
|
@@ -17559,11 +18106,14 @@ async function loadUsers({ deps, adapter, mapping, state, save, delta = false })
|
|
|
17559
18106
|
// src/migrate/loaders/credentials.ts
|
|
17560
18107
|
var CREDENTIALS_BATCH_MAX = 500;
|
|
17561
18108
|
var KEY = "users:credentials";
|
|
18109
|
+
var IDENTITIES_PER_USER_MAX = 8;
|
|
17562
18110
|
function credentialEntry(row2, withHashes) {
|
|
18111
|
+
const identities = (row2.identities ?? []).slice(0, IDENTITIES_PER_USER_MAX);
|
|
17563
18112
|
return {
|
|
17564
18113
|
id: row2.id,
|
|
17565
18114
|
...withHashes && row2.password_hash ? { password_hash: row2.password_hash } : {},
|
|
17566
|
-
...row2.email_verified ? { email_verified: true } : {}
|
|
18115
|
+
...row2.email_verified ? { email_verified: true } : {},
|
|
18116
|
+
...identities.length > 0 ? { identities } : {}
|
|
17567
18117
|
};
|
|
17568
18118
|
}
|
|
17569
18119
|
async function loadCredentials2({ deps, adapter, state, save, opts, delta = false }) {
|
|
@@ -17576,11 +18126,17 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
17576
18126
|
const sent = hashesFor(state, KEY);
|
|
17577
18127
|
if (cursors[KEY] === null) {
|
|
17578
18128
|
deps.log(delta ? "credentials: incremental pass already complete for this --since window \u2014 skipping" : `credentials: already complete \u2014 skipping (\`data --since <time>\` re-reads it)`);
|
|
18129
|
+
if (opts.withIdentities) {
|
|
18130
|
+
deps.log(" \u26A0 --with-oauth-identities: social links attach only to users the import CREATES \u2014 users already imported by an earlier run are kept as they are and get no links. To carry the links, import into a fresh target (`vxil dev down` / a new project) with --with-password-hashes --with-oauth-identities.");
|
|
18131
|
+
}
|
|
17579
18132
|
return result;
|
|
17580
18133
|
}
|
|
17581
18134
|
const reasons = /* @__PURE__ */ new Map();
|
|
17582
18135
|
let withPassword = 0;
|
|
17583
|
-
|
|
18136
|
+
let withLinks = 0;
|
|
18137
|
+
let linked = 0;
|
|
18138
|
+
const readOpts = opts.withIdentities ? { identities: true } : void 0;
|
|
18139
|
+
for await (const batch of adapter.readCredentials(cursors[KEY] ?? void 0, readOpts)) {
|
|
17584
18140
|
const entries = [];
|
|
17585
18141
|
const digests = [];
|
|
17586
18142
|
for (const row2 of batch.rows) {
|
|
@@ -17592,6 +18148,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
17592
18148
|
continue;
|
|
17593
18149
|
}
|
|
17594
18150
|
if (entry.password_hash) withPassword++;
|
|
18151
|
+
if (entry.identities) withLinks++;
|
|
17595
18152
|
entries.push(entry);
|
|
17596
18153
|
digests.push(h);
|
|
17597
18154
|
}
|
|
@@ -17609,6 +18166,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
17609
18166
|
return result;
|
|
17610
18167
|
}
|
|
17611
18168
|
const data2 = res.body.data ?? {};
|
|
18169
|
+
linked += typeof data2.identities_linked === "number" ? data2.identities_linked : 0;
|
|
17612
18170
|
const taken = (data2.results ?? []).filter((r) => r.status === "skipped" && r.reason === "id_taken").length;
|
|
17613
18171
|
if (taken > 0) {
|
|
17614
18172
|
result.errors.push(`credentials: ${taken} user id(s) already belong to another vxil project in this environment (user ids are unique across projects) \u2014 most often a rehearsal dev backend that imported the same users. Delete it (\`vxil dev down\`) or the project that holds them, then re-run: the cursor was NOT advanced and the import is idempotent. Left as is, those users would get a new id at sign-up and lose their data.`);
|
|
@@ -17630,10 +18188,13 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
17630
18188
|
if (batch.nextCursor === null) break;
|
|
17631
18189
|
}
|
|
17632
18190
|
if (deps.dryRun) {
|
|
17633
|
-
deps.log(`credentials: DRY-RUN \u2014 would import ${result.read - result.skipped} identit(ies)` + (opts.withHashes ? `, ${withPassword} with a password hash` : " (no hashes \u2014 --with-password-hashes not set)") + (opts.oidcIssuer ? ", each linked to the source issuer for the session hand-over" : "") + "; nothing written");
|
|
18191
|
+
deps.log(`credentials: DRY-RUN \u2014 would import ${result.read - result.skipped} identit(ies)` + (opts.withHashes ? `, ${withPassword} with a password hash` : " (no hashes \u2014 --with-password-hashes not set)") + (opts.oidcIssuer ? ", each linked to the source issuer for the session hand-over" : "") + (opts.withIdentities ? `, ${withLinks} with social sign-in links` : "") + "; nothing written");
|
|
17634
18192
|
} else {
|
|
17635
18193
|
cursors[KEY] = null;
|
|
17636
18194
|
save();
|
|
18195
|
+
if (opts.withIdentities) {
|
|
18196
|
+
deps.log(`credentials: ${linked} social sign-in link(s) created for ${withLinks} user(s) \u2014 a link is made only for an identity this import creates or still holds, and a subject already linked stays put`);
|
|
18197
|
+
}
|
|
17637
18198
|
}
|
|
17638
18199
|
if (reasons.size > 0) {
|
|
17639
18200
|
const list = [...reasons].map(([r, n]) => `${r}=${n}`).join(" ");
|
|
@@ -18710,6 +19271,7 @@ async function runData(deps, plan, adapter, opts = {}) {
|
|
|
18710
19271
|
save: g.save,
|
|
18711
19272
|
...opts.pkColumns?.[table] !== void 0 ? { pkColumn: opts.pkColumns[table] } : {},
|
|
18712
19273
|
...opts.itemStatus !== void 0 ? { itemStatus: opts.itemStatus } : {},
|
|
19274
|
+
...opts.keepTimestamps ? { keepTimestamps: true } : {},
|
|
18713
19275
|
...delta ? { delta } : {}
|
|
18714
19276
|
});
|
|
18715
19277
|
results.push(r);
|
|
@@ -19063,7 +19625,14 @@ async function runMigrateData(io, opts = {}) {
|
|
|
19063
19625
|
if (opts.oidcIssuer !== void 0 && !/^https:\/\/[^\s?#]+$/.test(opts.oidcIssuer)) {
|
|
19064
19626
|
throw new Error(`--oidc-issuer '${opts.oidcIssuer}' must be an https URL with no query or fragment (Supabase: https://<ref>.supabase.co/auth/v1)`);
|
|
19065
19627
|
}
|
|
19066
|
-
|
|
19628
|
+
if (opts.withOauthIdentities && !opts.withPasswordHashes) {
|
|
19629
|
+
throw new Error("--with-oauth-identities needs --with-password-hashes in the same run: the import creates each user once, so a user with both a password and a social link would otherwise lose the password for good");
|
|
19630
|
+
}
|
|
19631
|
+
const credentials = opts.withPasswordHashes || opts.oidcIssuer !== void 0 || opts.withOauthIdentities ? {
|
|
19632
|
+
withHashes: opts.withPasswordHashes === true,
|
|
19633
|
+
...opts.oidcIssuer !== void 0 ? { oidcIssuer: opts.oidcIssuer } : {},
|
|
19634
|
+
...opts.withOauthIdentities ? { withIdentities: true } : {}
|
|
19635
|
+
} : void 0;
|
|
19067
19636
|
const tableNames = new Set(plan.tables.map((t) => t.table));
|
|
19068
19637
|
for (const o of opts.only ?? []) {
|
|
19069
19638
|
if (!DATA_STEPS.has(o) && !tableNames.has(o)) {
|
|
@@ -19098,7 +19667,8 @@ async function runMigrateData(io, opts = {}) {
|
|
|
19098
19667
|
...opts.prune ? { prune: true } : {},
|
|
19099
19668
|
...specs ? { payments: specs } : {},
|
|
19100
19669
|
...credentials ? { credentials } : {},
|
|
19101
|
-
...opts.fileLinks ? { fileLinks: opts.fileLinks } : {}
|
|
19670
|
+
...opts.fileLinks ? { fileLinks: opts.fileLinks } : {},
|
|
19671
|
+
...opts.keepTimestamps ? { keepTimestamps: true } : {}
|
|
19102
19672
|
});
|
|
19103
19673
|
io.log(`${execute ? "\nmigrate data" : "\nmigrate data (DRY-RUN \u2014 zero writes)"}${since ? ` \u2014 incremental since ${since}${opts.prune ? " + prune" : ""}` : ""}:`);
|
|
19104
19674
|
for (const r of results) io.log(formatLoadResult(r));
|
|
@@ -20266,7 +20836,7 @@ export default defineConfig({
|
|
|
20266
20836
|
"functions": {
|
|
20267
20837
|
"prune-receipts.ts": "// prune-receipts.ts \u2014 THE GARBAGE COLLECTOR (a vxil function, cron trigger).\n//\n// Trigger: cron `30 */6 * * *` (every 6 hours).\n//\n// A push service accepts a message and answers \"ok\" long before it knows whether\n// the device still exists. The real verdict arrives later, as a RECEIPT keyed by\n// the id the send returned. Almost nobody collects them, which is why push\n// registries only ever grow: dead tokens are re-sent to forever, quota is spent\n// on handsets that were wiped a year ago, and delivery rates look worse than\n// they are.\n//\n// This cron closes that loop:\n// 1. resume the walk of `push_tokens` where the previous tick stopped (the\n// cursor lives in `push_state`), collecting rows that carry a `receipt_id`,\n// 2. ask the push service for those receipts, in batches,\n// 3. `DeviceNotRegistered` \u2192 DELETE the row (\u2605 invariant 3),\n// any other error \u2192 count it and keep the device,\n// ok \u2192 clear the receipt id; there is nothing left to check,\n// no receipt and older than RECEIPT_MAX_AGE_H \u2192 clear it (the service no\n// longer has it; asking forever would pin the row),\n// Every clear is a PATCH with If-Match on the row version that was read:\n// if `send-push` parked a NEWER receipt id on the device meanwhile, the\n// clear is refused (409) and the newer id stays for the next walk,\n// 4. save the cursor \u2014 or '' when the walk reached the oldest row, so the\n// next tick starts again from the newest.\n//\n// Everything is bounded: one tick reads at most MAX_ROWS rows and acts on at\n// most MAX_ACTIONS of them, so its outbound calls stay in the low hundreds.\n// Because the cursor persists, a registry of ANY size is walked end to end over\n// successive ticks \u2014 MAX_ROWS \xD7 4 ticks a day. (The walk follows the default\n// newest-first order; rows registered while a walk is in progress are covered\n// by the next walk.)\n\n// cron-walk: persisted-cursor \u2014 the walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope } from '@vxil/sdk';\n\nconst RECEIPTS_URL = 'https://exp.host/--/api/v2/push/getReceipts';\n/** Receipt ids per request. */\nconst RECEIPT_BATCH = 300;\n/** Token rows examined per cron tick. */\nconst MAX_ROWS = 2000;\n/** Rows with a pending receipt acted on per tick (each costs one PATCH or DELETE). */\nconst MAX_ACTIONS = 300;\n/** A receipt id parked longer than this is cleared as unknowable. */\nconst RECEIPT_MAX_AGE_H = 48;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** This cron's row in `push_state`. */\nconst STATE_KEY = 'prune-receipts';\n\ntype Env = CronFunctionEnvelope;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; receipt_id?: string; receipt_at?: string; failures?: number };\n}\ninterface Receipt { status?: string; message?: string; details?: { error?: string } }\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\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 = env.scoped_jwts?.cms;\n if (!cms) return Response.json({ error: 'missing cms scope' }, { status: 403 });\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n // \u2500\u2500 1. Resume the walk; collect pending receipts. \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 const state = await readState(base, H, STATE_KEY);\n if (state === 'error') return Response.json({ error: 'state_read_failed' }, { status: 502 });\n const startedAt = state?.data.cursor || null;\n const pending = new Map<string, TokenRow>(); // receipt_id \u2192 row\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n while (scanned < MAX_ROWS && pending.size < MAX_ACTIONS) {\n // NOTE: no `sort=` here on purpose. In the DEFAULT order (newest first)\n // a cursor is a plain item id, which is what we persist between ticks\n // and resume `item_id <` from. Paging beats ordering here.\n const url = `${base}/v1/cms/items/push_tokens?limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');\n const res = await fetch(url, { headers: H });\n if (!res.ok) return Response.json({ error: 'list_failed', status: res.status }, { status: 502 });\n const body = (await res.json()) as { data?: { items?: TokenRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n let stoppedEarly = false;\n for (const it of items) {\n scanned++;\n if (it.data.receipt_id) pending.set(it.data.receipt_id, it);\n cursor = it.item_id; // resume AFTER the last row actually looked at\n if (pending.size >= MAX_ACTIONS || scanned >= MAX_ROWS) { stoppedEarly = true; break; }\n }\n if (stoppedEarly) break;\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n // Reached the oldest row \u2192 the next tick starts again from the newest.\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // \u2500\u2500 2 + 3. Ask for verdicts and act on them. \u2500\u2500\u2500\u2500\u2500\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 const ids = [...pending.keys()];\n let checked = 0;\n let pruned = 0;\n let cleared = 0;\n let expired = 0;\n let raced = 0;\n const errors: string[] = [];\n const now = Date.now();\n\n for (let i = 0; i < ids.length; i += RECEIPT_BATCH) {\n const slice = ids.slice(i, i + RECEIPT_BATCH);\n const res = await fetch(RECEIPTS_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({ ids: slice }),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`receipts request returned ${res?.status ?? 'network error'}`);\n continue;\n }\n // Receipts come back keyed by id \u2014 a receipt that is not ready yet is\n // simply ABSENT, which is not an error: it is checked again next walk.\n const map = (((await res.json().catch(() => ({}))) as { data?: Record<string, Receipt> }).data) ?? {};\n for (const id of slice) {\n const receipt = map[id];\n const row = pending.get(id)!;\n if (!receipt) {\n // Not ready \u2014 or no longer kept by the service. Past the age bound\n // the id can never be answered: clear it so the row is not pinned.\n const at = row.data.receipt_at ? Date.parse(row.data.receipt_at) : NaN;\n if (Number.isFinite(at) && now - at > RECEIPT_MAX_AGE_H * 3_600_000) {\n if (await patch(base, H, row, { receipt_id: '', checked_at: iso() })) expired++; else raced++;\n }\n continue;\n }\n checked++;\n if (receipt.status === 'ok') {\n // Delivered. Nothing more to check for this device.\n if (await patch(base, H, row, { receipt_id: '', failures: 0, checked_at: iso() })) cleared++; else raced++;\n continue;\n }\n const reason = receipt.details?.error ?? receipt.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // \u2605 INVARIANT 3. The device is gone \u2014 the row goes with it. This is\n // also the ONLY safe automatic delete: every other error is transient\n // or about the message, not about the device's existence.\n await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, { method: 'DELETE', headers: H })\n .catch(() => null);\n pruned++;\n continue;\n }\n if (!await patch(base, H, row, {\n receipt_id: '',\n failures: (row.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n checked_at: iso(),\n })) raced++;\n errors.push(reason);\n }\n }\n\n // \u2500\u2500 4. Save where the walk stopped (If-Match: a concurrent tick wins). \u2500\u2500\u2500\u2500\n const saved = await writeState(base, H, STATE_KEY, state, nextCursor);\n\n return Response.json({\n scanned, pending: pending.size, checked, pruned, cleared, expired, raced,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved, errors: errors.slice(0, 10),\n });\n },\n};\n\n/** Clear/record against the version that was READ. false = the row changed\n * meanwhile (409: a newer receipt is parked \u2014 leave it) or the write failed. */\nasync function patch(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** This cron's `push_state` row, null when it does not exist yet. */\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** Persist the cursor. First tick creates the row (a racing tick's 409 on the\n * unique `key` is fine \u2014 that tick saved its own); later ticks PATCH with\n * If-Match so two overlapping ticks cannot both move it. */\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: iso() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\nconst iso = () => new Date().toISOString();\n",
|
|
20268
20838
|
"register-token.ts": "// register-token.ts \u2014 THE DEVICE REGISTRY (a vxil function with five bindings).\n//\n// http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n// cmsHook a new `push_token_claims` row (the app's async re-own request)\n// webhook `auth.user.erased` / `user.erased` / `user.deleted` (forget the user)\n//\n// One bundle, because all three are the same job: keeping \"which user holds\n// which device\" true. The envelope's `trigger` says which door was used.\n//\n// \u2500\u2500 http: register \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Call it every time your app starts and every time the OS hands you a new push\n// token. It is idempotent, and it enforces the two registry invariants:\n//\n// 1. ONE TOKEN, ONE USER. `token` is declared `unique`, so the platform \u2014\n// not this code \u2014 decides who wins a race. Two devices registering the\n// same token at the same instant produce exactly one row; the loser sees\n// 409 `unique_violation`.\n// 2. RE-OWN ON CHANGE. A token that comes back under a DIFFERENT user is\n// REASSIGNED, not duplicated. This is the case that matters: a resold\n// phone, a shared tablet, a sign-out/sign-in on the same handset. Without\n// it, the previous owner's notifications keep arriving on someone else's\n// lock screen.\n//\n// IDENTITY. If the call carries a verified end-user session, `envelope.end_user`\n// is populated by the platform and THAT id wins \u2014 a body field can never\n// impersonate a signed-in user. In server mode (your backend, a server key)\n// there is no verified principal and `user_id` from the body is used.\n//\n// SERVER MODE re-owns immediately (a tenant-wide read finds the row whoever\n// owns it). END-USER MODE cannot see another user's row \u2014 the owner scope is\n// the fail-safe working \u2014 so the app's call files a CLAIM in\n// `push_token_claims` (owned by the caller) and answers 202\n// `{ action: 'reown_queued' }`; the cmsHook below finishes the move.\n//\n// \u2500\u2500 cmsHook: finish an async re-own \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Runs in SERVER mode (a platform-delivered hook carries no end-user), so it can\n// read and move any row: re-read the claim, re-own (or create) the token row for\n// the claimant, delete the claim. At-least-once and re-delivered on failure\n// (`retry`), so every step is idempotent: a claim that is already gone is done.\n//\n// \u2500\u2500 webhook: erasure and deletion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// `auth.user.erased { user_id }`, `user.erased { id }` and `user.deleted { id }`\n// (the admin delete without erase) delete every device row, pending claim and\n// the preferences row of that user \u2014 device tokens, time zone and activity are\n// personal data, and a deleted account must stop getting reminder nudges.\n// Deliberately NOT `auth.session.revoked`: that event fires on every\n// refresh-token rotation, not only on sign-out. A MERGE or REKEY\n// (`auth.user.merged` / `.rekeyed`, guest \u2192 registered) needs nothing here: the\n// app's next start registers its token under the new id, which re-owns it.\n\nimport type { CmsHookFunctionEnvelope, HttpFunctionEnvelope, WebhookFunctionEnvelope } from '@vxil/sdk';\n\ntype RegisterBody = { token?: string; user_id?: string; platform?: string; locale?: string };\ntype Env =\n | HttpFunctionEnvelope<RegisterBody>\n | CmsHookFunctionEnvelope\n | WebhookFunctionEnvelope<{ user_id?: string; id?: string }>;\ninterface TokenRow { item_id: string; version?: number; data: { token?: string; end_user?: string } }\ninterface ClaimRow { item_id: string; data: { token?: string; end_user?: string; platform?: string; locale?: string } }\n\nconst PLATFORMS = new Set(['ios', 'android', 'web']);\n/** Rows deleted per erasure attempt \u2014 a person has a handful of devices; a\n * bigger set is finished by the next attempt (each deletes the next pages). */\nconst ERASE_MAX = 200;\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 = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n\n if (env.trigger === 'cms-hook') return finishClaim(base, H, env);\n if (env.trigger === 'webhook') return forgetUser(base, H, env);\n return register(base, H, env as HttpFunctionEnvelope<RegisterBody>);\n },\n};\n\n// \u2500\u2500 http \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function register(base: string, H: Record<string, string>, env: HttpFunctionEnvelope<RegisterBody>): Promise<Response> {\n const p = env.payload ?? {};\n const token = typeof p.token === 'string' ? p.token.trim() : '';\n // A VERIFIED principal always beats the body. Never trust a client-supplied\n // user id when the platform has already told you who is calling.\n const endUser = env.end_user?.id;\n const owner = endUser ?? (typeof p.user_id === 'string' ? p.user_id.trim() : '');\n if (!token) return json({ error: 'token is required' }, 400);\n if (!owner) return json({ error: 'user_id is required (or invoke with an end-user session)' }, 400);\n // The unique claim is capped at 256 chars; refuse early with a clear message\n // rather than letting the write fail deep in the stack.\n if (token.length > 256) return json({ error: 'token longer than 256 characters \u2014 store a hash instead' }, 400);\n\n const data = tokenData(token, owner, p.platform, p.locale);\n\n // \u2500\u2500 1. Try to create. The common case is one round-trip. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n if (created.ok) {\n const body = (await created.json()) as { data?: { item_id?: string } };\n return json({ registered: true, action: 'created', item_id: body.data?.item_id }, 201);\n }\n if (created.status !== 409) return json({ error: 'register_failed', status: created.status }, 502);\n\n // \u2500\u2500 2. 409 \u21D2 the token already exists. Find it and decide. \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // In SERVER mode this read is tenant-wide and finds the row whoever owns it.\n // In end-user mode it is owner-scoped, so a row belonging to someone else is\n // invisible \u2014 that is the fail-safe working as designed.\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502);\n if (!row) {\n if (!endUser) {\n // Server mode sees every row, so \"exists but not found\" is a race with a\n // concurrent delete. Retrying the call settles it.\n return json({ registered: false, error: 'conflict', hint: 'retry the call' }, 409);\n }\n // \u2605 The async re-own. File a claim (owned by the caller \u2014 the platform\n // stamps the verified owner); the server-mode cmsHook moves the token.\n const claim = await fetch(`${base}/v1/cms/items/push_token_claims`, {\n method: 'POST', headers: H,\n body: JSON.stringify({ status: 'published', data: claimData(token, owner, p.platform, p.locale) }),\n });\n if (!claim.ok) return json({ error: 'claim_failed', status: claim.status }, 502);\n const c = (await claim.json()) as { data?: { item_id?: string } };\n return json({ registered: false, action: 'reown_queued', claim_id: c.data?.item_id }, 202);\n }\n\n const moved = await reown(base, H, row, data);\n if (!moved.ok) {\n // 409 version_conflict = a concurrent registration already moved it. The\n // registry is still correct; the caller can simply retry.\n return json({ registered: false, error: 'conflict', status: moved.status }, 409);\n }\n const previous = row.data.end_user;\n return json({\n registered: true,\n action: previous && previous !== owner ? 'reowned' : 'refreshed',\n item_id: row.item_id,\n ...(previous && previous !== owner ? { previous_owner: previous } : {}),\n }, 200);\n}\n\n// \u2500\u2500 cmsHook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function finishClaim(base: string, H: Record<string, string>, env: CmsHookFunctionEnvelope): Promise<Response> {\n const { collection, item_id: claimId } = env.payload ?? ({} as CmsHookFunctionEnvelope['payload']);\n // The platform filters on the binding's collection; this guard is belt and braces.\n if (collection !== 'push_token_claims' || !claimId) return json({ skipped: true }, 200);\n\n const got = await fetch(`${base}/v1/cms/items/push_token_claims/${claimId}`, { headers: H });\n if (got.status === 404) return json({ done: true, reason: 'claim already handled' }, 200);\n if (!got.ok) return json({ error: 'claim_read_failed', status: got.status }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const claim = ((await got.json()) as { data?: ClaimRow }).data;\n const token = claim?.data.token;\n const owner = claim?.data.end_user;\n if (!token || !owner) {\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, reason: 'empty claim dropped' }, 200);\n }\n\n const data = tokenData(token, owner, claim.data.platform, claim.data.locale);\n const row = await findToken(base, H, token);\n if (row === 'error') return json({ error: 'lookup_failed' }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n let action: string;\n if (!row) {\n // The holder deleted it in the meantime: the claimant simply registers it.\n const created = await fetch(`${base}/v1/cms/items/push_tokens`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n });\n // 409 = it was registered again concurrently; the next delivery re-reads.\n if (!created.ok) return json({ error: 'create_failed', status: created.status }, 502);\n action = 'created';\n } else {\n const moved = await reown(base, H, row, data);\n if (!moved.ok) return json({ error: 'reown_failed', status: moved.status }, 502); // another attempt re-reads (maxAttempts: 3)\n action = row.data.end_user === owner ? 'refreshed' : 'reowned';\n }\n await deleteItem(base, H, 'push_token_claims', claimId);\n return json({ done: true, action, claim_id: claimId }, 200);\n}\n\n// \u2500\u2500 webhook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function forgetUser(\n base: string, H: Record<string, string>, env: WebhookFunctionEnvelope<{ user_id?: string; id?: string }>,\n): Promise<Response> {\n const event = env.payload?.event;\n const d = env.payload?.data;\n const fields = d && !('truncated' in d) ? d : null;\n // auth.user.erased carries `user_id`; the admin erase and delete carry `id`.\n const userId = event === 'auth.user.erased' ? fields?.user_id\n : event === 'user.erased' || event === 'user.deleted' ? fields?.id : undefined;\n if (typeof userId !== 'string' || !userId) return json({ skipped: true, event }, 200);\n\n let deleted = 0;\n let failed = 0;\n for (const coll of ['push_tokens', 'push_token_claims', 'push_prefs']) {\n const filter = encodeURIComponent(JSON.stringify({ end_user: userId }));\n while (deleted + failed < ERASE_MAX) {\n // Always the FIRST page: what was deleted is gone, so the next read\n // returns the next rows. A row that refuses to delete ends the loop.\n const res = await fetch(`${base}/v1/cms/items/${coll}?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status, deleted }, 502); // non-2xx \u2192 another attempt (maxAttempts: 3)\n const items = ((await res.json()) as { data?: { items?: Array<{ item_id: string }> } }).data?.items ?? [];\n if (items.length === 0) break;\n let progress = 0;\n for (const it of items) {\n if (await deleteItem(base, H, coll, it.item_id)) { deleted++; progress++; } else failed++;\n }\n if (progress === 0) break;\n }\n }\n // A failed delete \u2014 or a set larger than one attempt's ERASE_MAX \u2014 answers\n // non-2xx: the binding declares maxAttempts: 3, so the jobs ladder hands it\n // back for another attempt, which deletes the rest.\n const unfinished = failed > 0 || deleted >= ERASE_MAX;\n return json({ user_id: userId, deleted, failed, done: !unfinished }, unfinished ? 502 : 200);\n}\n\n// \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction tokenData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner, last_seen: new Date().toISOString(), failures: 0, last_error: '',\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nfunction claimData(token: string, owner: string, platform: unknown, locale: unknown): Record<string, unknown> {\n return {\n token, end_user: owner,\n ...(typeof platform === 'string' && PLATFORMS.has(platform) ? { platform } : {}),\n ...(typeof locale === 'string' && locale ? { locale: locale.slice(0, 16) } : {}),\n };\n}\n\nasync function findToken(base: string, H: Record<string, string>, token: string): Promise<TokenRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ token }));\n const found = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=1`, { headers: H });\n if (!found.ok) return 'error';\n return ((await found.json()) as { data?: { items?: TokenRow[] } }).data?.items?.[0] ?? null;\n}\n\n/** \u2605 INVARIANT 2: re-own (or just refresh, when the owner is unchanged).\n * If-Match makes the reassignment atomic against a concurrent registration. */\nfunction reown(base: string, H: Record<string, string>, row: TokenRow, data: Record<string, unknown>): Promise<Response> {\n return fetch(`${base}/v1/cms/items/push_tokens/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data }),\n });\n}\n\n/** true when the row is gone afterwards (a 404 counts: someone else deleted it). */\nasync function deleteItem(base: string, H: Record<string, string>, coll: string, itemId: string): Promise<boolean> {\n const res = await fetch(`${base}/v1/cms/items/${coll}/${itemId}`, { method: 'DELETE', headers: H }).catch(() => null);\n return !!res && (res.ok || res.status === 404);\n}\n\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n",
|
|
20269
|
-
"send-push.ts": "// send-push.ts \u2014 THE SENDER (a vxil function, two bindings).\n//\n// http POST /v1/fn/send-push \u2014 send now (below).\n// cron `0 * * * *` \u2014 THE REMINDER LADDER (further below).\n//\n// POST /v1/fn/send-push\n// {\n// \"user_ids\": [\"usr_a\", \"usr_b\"],\n// \"messages\": { // keyed by locale; \"default\" is required\n// \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n// \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n// \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n// },\n// \"data\": { \"screen\": \"billing\" } // optional payload handed to your app\n// }\n//\n// It resolves each user's live devices, picks the copy for THAT DEVICE'S locale\n// (captured at registration), and posts to the push service in batches.\n//\n// LOCALIZATION IS PER DEVICE, NOT PER USER. One person can carry an English\n// phone and an Arabic tablet. Because `locale` lives on the token row, the right\n// copy goes to each device with no extra lookup.\n//\n// Two failure kinds, handled differently:\n// \u2022 an IMMEDIATE per-message error (`DeviceNotRegistered`) \u2014 the token is dead\n// right now; delete the row.\n// \u2022 an accepted message \u2014 the service returns a RECEIPT id, and the real\n// verdict arrives later. Store the id (and when); `prune-receipts` collects it.\n//\n// \u2500\u2500 The reminder ladder (cron, hourly) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Re-engagement for users who stopped opening the app: one nudge after 1 day\n// of inactivity, one after 3, one after 7 \u2014 then silence until they come back.\n// Per user, from `push_prefs` (the app writes its own row):\n// \u2022 `reminders: false` \u2192 never;\n// \u2022 quiet hours [quiet_start, quiet_end) in the user's `timezone` \u2192 not now\n// (the next hourly tick after the window sends it);\n// \u2022 `last_active` + `ladder_step` decide the step; the APP resets\n// `ladder_step` to 0 when it comes to the foreground.\n// ONE nudge per tick at most, and never a catch-up burst: when several rungs\n// are due at once (a user already idle for a week when the ladder first sees\n// them \u2014 the kit added to a live app, reminders turned back on, a held cron),\n// only the HIGHEST due rung is sent and the steps below it are skipped. And a\n// rung waits its own gap after the previous nudge (`last_reminded_at`), so\n// 1 d \u2192 3 d \u2192 7 d stay 2 and 4 days apart even when a step went out late.\n// Each step is CLAIMED with If-Match on the prefs row BEFORE the send, so two\n// overlapping ticks can never send it twice (a send that then fails is a\n// missed nudge, never a duplicate one). The walk only reads users inactive for\n// at least the first step and not yet through the ladder (`ladder_step` below\n// its length \u2014 a `derive` hook writes 0 when the app leaves it out), and\n// resumes where the last tick stopped (the cursor lives in `push_state`), so\n// it covers any number of users over successive ticks: MAX_LADDER_ROWS read and\n// MAX_REMINDERS sent per tick. A skipped tick is harmless \u2014 the next resumes.\n\n// Expo's push endpoint. For raw FCM: 'https://fcm.googleapis.com/v1/projects/<id>/messages:send'\n// (and an OAuth bearer). For APNs: 'https://api.push.apple.com/3/device/<token>'\n// (one request per device, a JWT `authorization`, and an `apns-topic` header).\n// Whichever you pick, its HOST must be in the function's `egressAllow`.\n\n// cron-walk: persisted-cursor \u2014 the ladder walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst PUSH_URL = 'https://exp.host/--/api/v2/push/send';\n/** Expo accepts up to 100 messages in one request. */\nconst BATCH = 100;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** Devices resolved per invocation \u2014 a guardrail, not a limit of the platform. */\nconst MAX_DEVICES = 1000;\n\n/** The ladder: hours of inactivity \u2192 the nudge, per locale ('default' required).\n * Edit the copy; keep the hours ascending. */\nconst LADDER: ReadonlyArray<{ afterHours: number; messages: Messages }> = [\n { afterHours: 24, messages: {\n default: { title: 'Pick up where you left off', body: 'Your streak is waiting for you.' },\n ar: { title: '\u0623\u0643\u0645\u0644 \u0645\u0646 \u062D\u064A\u062B \u062A\u0648\u0642\u0641\u062A', body: '\u0633\u0644\u0633\u0644\u062A\u0643 \u0628\u0627\u0646\u062A\u0638\u0627\u0631\u0643.' },\n } },\n { afterHours: 72, messages: {\n default: { title: 'We saved your progress', body: 'It only takes a minute to get back on track.' },\n ar: { title: '\u062D\u0641\u0638\u0646\u0627 \u062A\u0642\u062F\u0645\u0643', body: '\u062F\u0642\u064A\u0642\u0629 \u0648\u0627\u062D\u062F\u0629 \u062A\u0643\u0641\u064A \u0644\u0644\u0639\u0648\u062F\u0629.' },\n } },\n { afterHours: 168, messages: {\n default: { title: 'Still there?', body: 'Here is what you missed this week.' },\n ar: { title: '\u0647\u0644 \u0645\u0627 \u0632\u0644\u062A \u0647\u0646\u0627\u061F', body: '\u0625\u0644\u064A\u0643 \u0645\u0627 \u0641\u0627\u062A\u0643 \u0647\u0630\u0627 \u0627\u0644\u0623\u0633\u0628\u0648\u0639.' },\n } },\n];\n/** `push_prefs` rows read per tick. */\nconst MAX_LADDER_ROWS = 2000;\n/** Users nudged per tick (each costs a claim PATCH, a device read and receipt PATCHes). */\nconst MAX_REMINDERS = 100;\n/** This cron's row in `push_state`. */\nconst LADDER_STATE_KEY = 'reminder-ladder';\n\ntype Copy = { title?: string; body?: string };\ntype Messages = Record<string, Copy>;\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{\n user_ids?: string[];\n messages?: Messages;\n data?: Record<string, unknown>;\n}>;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; end_user?: string; locale?: string; failures?: number };\n}\n/** One entry of Expo's response array, positionally matched to the request. */\ninterface Ticket { status?: string; id?: string; message?: string; details?: { error?: string } }\ninterface PrefRow {\n item_id: string;\n version?: number;\n data: {\n end_user?: string; timezone?: string; reminders?: boolean; quiet_start?: number; quiet_end?: number;\n last_active?: string; ladder_step?: number; last_reminded_at?: string;\n };\n}\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\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 = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n if (env.trigger === 'cron') return ladder(base, H);\n\n const p = (env as Exclude<Env, CronFunctionEnvelope>).payload ?? {};\n const userIds = Array.isArray(p.user_ids) ? p.user_ids.filter((u) => typeof u === 'string' && u) : [];\n const messages = p.messages ?? {};\n if (userIds.length === 0) return json({ error: 'user_ids is required' }, 400);\n if (!messages.default?.title && !messages.default?.body) {\n return json({ error: 'messages.default { title, body } is required' }, 400);\n }\n const devices = await resolveDevices(base, H, userIds);\n if (devices.length === 0) return json({ sent: 0, devices: 0, note: 'no registered devices' }, 200);\n const out = await sendTo(base, H, devices.map((d) => ({ device: d, messages })), p.data);\n return json({ devices: devices.length, ...out }, 200);\n },\n};\n\n/** \u2500\u2500 1. Resolve devices. One filtered read per user (owner-scoped by value,\n * not by principal \u2014 this runs in server mode). */\nasync function resolveDevices(base: string, H: Record<string, string>, userIds: string[]): Promise<TokenRow[]> {\n const devices: TokenRow[] = [];\n for (const uid of userIds) {\n if (devices.length >= MAX_DEVICES) break;\n const filter = encodeURIComponent(JSON.stringify({ end_user: uid }));\n const res = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) continue;\n const items = ((await res.json()) as { data?: { items?: TokenRow[] } }).data?.items ?? [];\n for (const it of items) if (it.data.token) devices.push(it);\n }\n return devices;\n}\n\n/** \u2500\u2500 2 + 3. One message per device in that device's language; send in\n * batches; reconcile each ticket back to its device by index. */\nasync function sendTo(\n base: string, H: Record<string, string>,\n targets: Array<{ device: TokenRow; messages: Messages }>, data?: Record<string, unknown>,\n): Promise<{ accepted: number; dropped: number; errors: string[] }> {\n const outgoing = targets.map(({ device, messages }) => {\n const copy = pick(messages, device.data.locale);\n return {\n to: device.data.token!,\n ...(copy.title ? { title: copy.title } : {}),\n ...(copy.body ? { body: copy.body } : {}),\n ...(data ? { data } : {}),\n sound: 'default',\n };\n });\n let accepted = 0;\n let dropped = 0;\n const errors: string[] = [];\n for (let i = 0; i < outgoing.length; i += BATCH) {\n const slice = outgoing.slice(i, i + BATCH);\n const res = await fetch(PUSH_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify(slice),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`push service returned ${res?.status ?? 'network error'} for batch ${i / BATCH}`);\n continue;\n }\n const tickets = (((await res.json().catch(() => ({}))) as { data?: Ticket[] }).data) ?? [];\n for (let k = 0; k < slice.length; k++) {\n const device = targets[i + k]!.device;\n const ticket = tickets[k];\n if (ticket?.status === 'ok' && ticket.id) {\n // Accepted, verdict pending. Park the receipt id (and when) for prune-receipts.\n accepted++;\n await patch(base, H, device, { receipt_id: ticket.id, receipt_at: new Date().toISOString(), failures: 0, last_error: '' });\n continue;\n }\n const reason = ticket?.details?.error ?? ticket?.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // Definitive: this token will never deliver again. \u2605 INVARIANT 3.\n await del(base, H, device);\n dropped++;\n continue;\n }\n // Anything else (MessageTooBig, MessageRateExceeded, a transient fault)\n // is NOT a reason to delete a device. Count it and move on.\n await patch(base, H, device, {\n failures: (device.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n });\n errors.push(reason);\n }\n }\n return { accepted, dropped, errors: errors.slice(0, 10) };\n}\n\n// \u2500\u2500 The reminder ladder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function ladder(base: string, H: Record<string, string>): Promise<Response> {\n const now = new Date();\n const state = await readState(base, H, LADDER_STATE_KEY);\n if (state === 'error') return json({ error: 'state_read_failed' }, 502);\n const startedAt = state?.data.cursor || null;\n // Only users idle for at least the first step, with a rung still ahead of\n // them, can be due: both terms ride slot indexes (`last_active` t1,\n // `ladder_step` n1), so active users and finished ladders are never read.\n const idleSince = new Date(now.getTime() - LADDER[0]!.afterHours * 3_600_000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({\n last_active: { $lt: idleSince }, ladder_step: { $lt: LADDER.length },\n }));\n\n const due: Array<{ row: PrefRow; step: number }> = [];\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n let quiet = 0;\n scan: while (scanned < MAX_LADDER_ROWS) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs?filter=${filter}&limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status }, 502);\n const body = (await res.json()) as { data?: { items?: PrefRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n for (const it of items) {\n scanned++;\n cursor = it.item_id;\n const step = dueStep(it.data, now);\n if (step !== null) {\n if (inQuietHours(it.data, now)) quiet++; // left for a later tick: the cursor wraps back to it\n else due.push({ row: it, step });\n }\n if (due.length >= MAX_REMINDERS || scanned >= MAX_LADDER_ROWS) break scan;\n }\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // Claim each step FIRST (If-Match): a concurrent tick that claimed it wins,\n // and this one skips the user \u2014 never a double nudge.\n const targets: Array<{ device: TokenRow; messages: Messages }> = [];\n let claimed = 0;\n for (const { row, step } of due) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data: { ladder_step: step + 1, last_reminded_at: now.toISOString() } }),\n }).catch(() => null);\n if (!res || !res.ok) continue;\n claimed++;\n for (const device of await resolveDevices(base, H, [row.data.end_user!])) {\n targets.push({ device, messages: LADDER[step]!.messages });\n }\n }\n const sent = targets.length > 0\n ? await sendTo(base, H, targets, { reason: 'reminder' })\n : { accepted: 0, dropped: 0, errors: [] as string[] };\n const saved = await writeState(base, H, LADDER_STATE_KEY, state, nextCursor);\n return json({\n ladder: true, scanned, due: due.length, quiet, claimed, devices: targets.length, ...sent,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved,\n }, 200);\n}\n\n/** The ladder step due for this user now, or null. Exported for tests.\n * The HIGHEST rung whose idle threshold is met (the claim then moves\n * `ladder_step` past it, so the rungs below are skipped, never sent late), and\n * only once that rung's gap since the previous nudge has passed. */\nexport function dueStep(p: PrefRow['data'], now: Date): number | null {\n if (p.reminders === false || !p.end_user || !p.last_active) return null;\n const idleH = (now.getTime() - Date.parse(p.last_active)) / 3_600_000;\n if (!Number.isFinite(idleH)) return null;\n const from = Math.max(0, Math.trunc(p.ladder_step ?? 0));\n let step: number | null = null;\n for (let r = from; r < LADDER.length; r++) if (idleH >= LADDER[r]!.afterHours) step = r;\n if (step === null) return null;\n if (step > 0 && p.last_reminded_at) {\n const sinceH = (now.getTime() - Date.parse(p.last_reminded_at)) / 3_600_000;\n const gapH = LADDER[step]!.afterHours - LADDER[step - 1]!.afterHours;\n if (Number.isFinite(sinceH) && sinceH < gapH) return null;\n }\n return step;\n}\n\n/** Is it inside the user's quiet hours [quiet_start, quiet_end) in THEIR zone?\n * The window may wrap midnight; equal or missing bounds mean none. An unknown\n * zone is evaluated in UTC. Exported for tests. */\nexport function inQuietHours(p: PrefRow['data'], now: Date): boolean {\n const start = p.quiet_start;\n const end = p.quiet_end;\n if (typeof start !== 'number' || typeof end !== 'number' || start === end) return false;\n const h = localHour(now, p.timezone);\n return start < end ? h >= start && h < end : h >= start || h < end;\n}\n\n/** One formatter per zone, kept for the isolate's life: building an Intl formatter is the\n * expensive part, and a tick can test hundreds of users in the same few zones\n * (CPU per invocation is capped \u2014 2,000 ms on a Free staging/development project, 50 ms on a Free dev slot). */\nconst ZONE_FMT = new Map<string, Intl.DateTimeFormat | null>();\nfunction zoneFormatter(tz: string): Intl.DateTimeFormat | null {\n if (!ZONE_FMT.has(tz)) {\n let f: Intl.DateTimeFormat | null = null;\n try { f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hourCycle: 'h23' }); } catch { /* unknown zone */ }\n if (ZONE_FMT.size < 500) ZONE_FMT.set(tz, f);\n return f;\n }\n return ZONE_FMT.get(tz)!;\n}\n\nfunction localHour(now: Date, tz: string | undefined): number {\n try {\n const part = zoneFormatter(tz || 'UTC')?.formatToParts(now).find((x) => x.type === 'hour');\n const h = Number(part?.value);\n if (Number.isInteger(h)) return h % 24;\n } catch { /* unknown zone \u2192 UTC below */ }\n return now.getUTCHours();\n}\n\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: new Date().toISOString() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** Locale match: exact ('pt-BR'), then the base language ('pt'), then default. */\nfunction pick(messages: Messages, locale: string | undefined): Copy {\n if (locale) {\n if (messages[locale]) return messages[locale]!;\n const bare = locale.split('-')[0]!;\n if (messages[bare]) return messages[bare]!;\n }\n return messages.default ?? {};\n}\n\nasync function patch(base: string, H: Record<string, string>, d: TokenRow, data: Record<string, unknown>) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n }).catch(() => null);\n}\nasync function del(base: string, H: Record<string, string>, d: TokenRow) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, { method: 'DELETE', headers: H }).catch(() => null);\n}\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
|
|
20839
|
+
"send-push.ts": "// send-push.ts \u2014 THE SENDER (a vxil function, two bindings).\n//\n// http POST /v1/fn/send-push \u2014 send now (below).\n// cron `0 * * * *` \u2014 THE REMINDER LADDER (further below).\n//\n// POST /v1/fn/send-push\n// {\n// \"user_ids\": [\"usr_a\", \"usr_b\"],\n// \"messages\": { // keyed by locale; \"default\" is required\n// \"default\": { \"title\": \"You're in\", \"body\": \"Pro is active on this device.\" },\n// \"ar\": { \"title\": \"\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644\", \"body\": \"\u0623\u0635\u0628\u062D \u0627\u0634\u062A\u0631\u0627\u0643 Pro \u0645\u0641\u0639\u0651\u0644\u0627\u064B.\" },\n// \"fr\": { \"title\": \"C'est actif\", \"body\": \"Pro est activ\xE9 sur cet appareil.\" }\n// },\n// \"data\": { \"screen\": \"billing\" } // optional payload handed to your app\n// }\n//\n// It resolves each user's live devices, picks the copy for THAT DEVICE'S locale\n// (captured at registration), and posts to the push service in batches.\n//\n// LOCALIZATION IS PER DEVICE, NOT PER USER. One person can carry an English\n// phone and an Arabic tablet. Because `locale` lives on the token row, the right\n// copy goes to each device with no extra lookup.\n//\n// Two failure kinds, handled differently:\n// \u2022 an IMMEDIATE per-message error (`DeviceNotRegistered`) \u2014 the token is dead\n// right now; delete the row.\n// \u2022 an accepted message \u2014 the service returns a RECEIPT id, and the real\n// verdict arrives later. Store the id (and when); `prune-receipts` collects it.\n//\n// \u2500\u2500 The reminder ladder (cron, hourly) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Re-engagement for users who stopped opening the app: one nudge after 1 day\n// of inactivity, one after 3, one after 7 \u2014 then silence until they come back.\n// Per user, from `push_prefs` (the app writes its own row):\n// \u2022 `reminders: false` \u2192 never;\n// \u2022 quiet hours [quiet_start, quiet_end) in the user's `timezone` \u2192 not now\n// (the next hourly tick after the window sends it);\n// \u2022 `last_active` + `ladder_step` decide the step; the APP resets\n// `ladder_step` to 0 when it comes to the foreground.\n// ONE nudge per tick at most, and never a catch-up burst: when several rungs\n// are due at once (a user already idle for a week when the ladder first sees\n// them \u2014 the kit added to a live app, reminders turned back on, a held cron),\n// only the HIGHEST due rung is sent and the steps below it are skipped. And a\n// rung waits its own gap after the previous nudge (`last_reminded_at`), so\n// 1 d \u2192 3 d \u2192 7 d stay 2 and 4 days apart even when a step went out late.\n// Each step is CLAIMED with If-Match on the prefs row BEFORE the send, so two\n// overlapping ticks can never send it twice (a send that then fails is a\n// missed nudge, never a duplicate one). The walk only reads users inactive for\n// at least the first step and not yet through the ladder (`ladder_step` below\n// its length \u2014 a `derive` hook writes 0 when the app leaves it out), and\n// resumes where the last tick stopped (the cursor lives in `push_state`), so\n// it covers any number of users over successive ticks: MAX_LADDER_ROWS read and\n// MAX_REMINDERS sent per tick. A skipped tick is harmless \u2014 the next resumes.\n\n// Expo's push endpoint. For raw FCM: 'https://fcm.googleapis.com/v1/projects/<id>/messages:send'\n// (and an OAuth bearer). For APNs: 'https://api.push.apple.com/3/device/<token>'\n// (one request per device, a JWT `authorization`, and an `apns-topic` header).\n// Whichever you pick, its HOST must be in the function's `egressAllow`.\n\n// cron-walk: persisted-cursor \u2014 the ladder walk's cursor lives in `push_state` and is saved with If-Match.\n\nimport type { CronFunctionEnvelope, HttpFunctionEnvelope } from '@vxil/sdk';\n\nconst PUSH_URL = 'https://exp.host/--/api/v2/push/send';\n/** Expo accepts up to 100 messages in one request. */\nconst BATCH = 100;\n/** cms page size \u2014 100 is the default `maxPageSize` for the cms feature. */\nconst PAGE = 100;\n/** Devices resolved per invocation \u2014 a guardrail, not a limit of the platform. */\nconst MAX_DEVICES = 1000;\n\n/** The ladder: hours of inactivity \u2192 the nudge, per locale ('default' required).\n * Edit the copy; keep the hours ascending. */\nconst LADDER: ReadonlyArray<{ afterHours: number; messages: Messages }> = [\n { afterHours: 24, messages: {\n default: { title: 'Pick up where you left off', body: 'Your streak is waiting for you.' },\n ar: { title: '\u0623\u0643\u0645\u0644 \u0645\u0646 \u062D\u064A\u062B \u062A\u0648\u0642\u0641\u062A', body: '\u0633\u0644\u0633\u0644\u062A\u0643 \u0628\u0627\u0646\u062A\u0638\u0627\u0631\u0643.' },\n } },\n { afterHours: 72, messages: {\n default: { title: 'We saved your progress', body: 'It only takes a minute to get back on track.' },\n ar: { title: '\u062D\u0641\u0638\u0646\u0627 \u062A\u0642\u062F\u0645\u0643', body: '\u062F\u0642\u064A\u0642\u0629 \u0648\u0627\u062D\u062F\u0629 \u062A\u0643\u0641\u064A \u0644\u0644\u0639\u0648\u062F\u0629.' },\n } },\n { afterHours: 168, messages: {\n default: { title: 'Still there?', body: 'Here is what you missed this week.' },\n ar: { title: '\u0647\u0644 \u0645\u0627 \u0632\u0644\u062A \u0647\u0646\u0627\u061F', body: '\u0625\u0644\u064A\u0643 \u0645\u0627 \u0641\u0627\u062A\u0643 \u0647\u0630\u0627 \u0627\u0644\u0623\u0633\u0628\u0648\u0639.' },\n } },\n];\n/** `push_prefs` rows read per tick. */\nconst MAX_LADDER_ROWS = 2000;\n/** Users nudged per tick (each costs a claim PATCH, a device read and receipt PATCHes). */\nconst MAX_REMINDERS = 100;\n/** This cron's row in `push_state`. */\nconst LADDER_STATE_KEY = 'reminder-ladder';\n\ntype Copy = { title?: string; body?: string };\ntype Messages = Record<string, Copy>;\ntype Env = CronFunctionEnvelope | HttpFunctionEnvelope<{\n user_ids?: string[];\n messages?: Messages;\n data?: Record<string, unknown>;\n}>;\ninterface TokenRow {\n item_id: string;\n version?: number;\n data: { token?: string; end_user?: string; locale?: string; failures?: number };\n}\n/** One entry of Expo's response array, positionally matched to the request. */\ninterface Ticket { status?: string; id?: string; message?: string; details?: { error?: string } }\ninterface PrefRow {\n item_id: string;\n version?: number;\n data: {\n end_user?: string; timezone?: string; reminders?: boolean; quiet_start?: number; quiet_end?: number;\n last_active?: string; ladder_step?: number; last_reminded_at?: string;\n };\n}\ninterface StateRow { item_id: string; version?: number; data: { cursor?: string } }\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 = env.scoped_jwts?.cms;\n if (!cms) return json({ error: 'missing cms scope' }, 403);\n const H = { authorization: `Bearer ${cms}`, 'content-type': 'application/json' };\n if (env.trigger === 'cron') return ladder(base, H);\n\n const p = (env as Exclude<Env, CronFunctionEnvelope>).payload ?? {};\n const userIds = Array.isArray(p.user_ids) ? p.user_ids.filter((u) => typeof u === 'string' && u) : [];\n const messages = p.messages ?? {};\n if (userIds.length === 0) return json({ error: 'user_ids is required' }, 400);\n if (!messages.default?.title && !messages.default?.body) {\n return json({ error: 'messages.default { title, body } is required' }, 400);\n }\n const devices = await resolveDevices(base, H, userIds);\n if (devices.length === 0) return json({ sent: 0, devices: 0, note: 'no registered devices' }, 200);\n const out = await sendTo(base, H, devices.map((d) => ({ device: d, messages })), p.data);\n return json({ devices: devices.length, ...out }, 200);\n },\n};\n\n/** \u2500\u2500 1. Resolve devices. One filtered read per user (owner-scoped by value,\n * not by principal \u2014 this runs in server mode). */\nasync function resolveDevices(base: string, H: Record<string, string>, userIds: string[]): Promise<TokenRow[]> {\n const devices: TokenRow[] = [];\n for (const uid of userIds) {\n if (devices.length >= MAX_DEVICES) break;\n const filter = encodeURIComponent(JSON.stringify({ end_user: uid }));\n const res = await fetch(`${base}/v1/cms/items/push_tokens?filter=${filter}&limit=50`, { headers: H });\n if (!res.ok) continue;\n const items = ((await res.json()) as { data?: { items?: TokenRow[] } }).data?.items ?? [];\n for (const it of items) if (it.data.token) devices.push(it);\n }\n return devices;\n}\n\n/** \u2500\u2500 2 + 3. One message per device in that device's language; send in\n * batches; reconcile each ticket back to its device by index. */\nasync function sendTo(\n base: string, H: Record<string, string>,\n targets: Array<{ device: TokenRow; messages: Messages }>, data?: Record<string, unknown>,\n): Promise<{ accepted: number; dropped: number; errors: string[] }> {\n const outgoing = targets.map(({ device, messages }) => {\n const copy = pick(messages, device.data.locale);\n return {\n to: device.data.token!,\n ...(copy.title ? { title: copy.title } : {}),\n ...(copy.body ? { body: copy.body } : {}),\n ...(data ? { data } : {}),\n sound: 'default',\n };\n });\n let accepted = 0;\n let dropped = 0;\n const errors: string[] = [];\n for (let i = 0; i < outgoing.length; i += BATCH) {\n const slice = outgoing.slice(i, i + BATCH);\n const res = await fetch(PUSH_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify(slice),\n }).catch(() => null);\n if (!res || !res.ok) {\n errors.push(`push service returned ${res?.status ?? 'network error'} for batch ${i / BATCH}`);\n continue;\n }\n const tickets = (((await res.json().catch(() => ({}))) as { data?: Ticket[] }).data) ?? [];\n for (let k = 0; k < slice.length; k++) {\n const device = targets[i + k]!.device;\n const ticket = tickets[k];\n if (ticket?.status === 'ok' && ticket.id) {\n // Accepted, verdict pending. Park the receipt id (and when) for prune-receipts.\n accepted++;\n await patch(base, H, device, { receipt_id: ticket.id, receipt_at: new Date().toISOString(), failures: 0, last_error: '' });\n continue;\n }\n const reason = ticket?.details?.error ?? ticket?.message ?? 'unknown';\n if (reason === 'DeviceNotRegistered') {\n // Definitive: this token will never deliver again. \u2605 INVARIANT 3.\n await del(base, H, device);\n dropped++;\n continue;\n }\n // Anything else (MessageTooBig, MessageRateExceeded, a transient fault)\n // is NOT a reason to delete a device. Count it and move on.\n await patch(base, H, device, {\n failures: (device.data.failures ?? 0) + 1,\n last_error: String(reason).slice(0, 300),\n });\n errors.push(reason);\n }\n }\n return { accepted, dropped, errors: errors.slice(0, 10) };\n}\n\n// \u2500\u2500 The reminder ladder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nasync function ladder(base: string, H: Record<string, string>): Promise<Response> {\n const now = new Date();\n const state = await readState(base, H, LADDER_STATE_KEY);\n if (state === 'error') return json({ error: 'state_read_failed' }, 502);\n const startedAt = state?.data.cursor || null;\n // Only users idle for at least the first step, with a rung still ahead of\n // them, can be due: both terms ride slot indexes (`last_active` t1,\n // `ladder_step` n1), so active users and finished ladders are never read.\n const idleSince = new Date(now.getTime() - LADDER[0]!.afterHours * 3_600_000).toISOString();\n const filter = encodeURIComponent(JSON.stringify({\n last_active: { $lt: idleSince }, ladder_step: { $lt: LADDER.length },\n }));\n\n const due: Array<{ row: PrefRow; step: number }> = [];\n let cursor: string | null = startedAt;\n let scanned = 0;\n let wrapped = false;\n let quiet = 0;\n scan: while (scanned < MAX_LADDER_ROWS) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs?filter=${filter}&limit=${PAGE}`\n + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''), { headers: H });\n if (!res.ok) return json({ error: 'list_failed', status: res.status }, 502);\n const body = (await res.json()) as { data?: { items?: PrefRow[]; next_cursor?: string | null } };\n const items = body.data?.items ?? [];\n for (const it of items) {\n scanned++;\n cursor = it.item_id;\n const step = dueStep(it.data, now);\n if (step !== null) {\n if (inQuietHours(it.data, now)) quiet++; // left for a later tick: the cursor wraps back to it\n else due.push({ row: it, step });\n }\n if (due.length >= MAX_REMINDERS || scanned >= MAX_LADDER_ROWS) break scan;\n }\n if (!body.data?.next_cursor || items.length === 0) { wrapped = true; break; }\n cursor = body.data.next_cursor;\n }\n const nextCursor = wrapped ? '' : (cursor ?? '');\n\n // Claim each step FIRST (If-Match): a concurrent tick that claimed it wins,\n // and this one skips the user \u2014 never a double nudge.\n const targets: Array<{ device: TokenRow; messages: Messages }> = [];\n let claimed = 0;\n for (const { row, step } of due) {\n const res = await fetch(`${base}/v1/cms/items/push_prefs/${row.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(row.version !== undefined ? { 'if-match': String(row.version) } : {}) },\n body: JSON.stringify({ data: { ladder_step: step + 1, last_reminded_at: now.toISOString() } }),\n }).catch(() => null);\n if (!res || !res.ok) continue;\n claimed++;\n for (const device of await resolveDevices(base, H, [row.data.end_user!])) {\n targets.push({ device, messages: LADDER[step]!.messages });\n }\n }\n const sent = targets.length > 0\n ? await sendTo(base, H, targets, { reason: 'reminder' })\n : { accepted: 0, dropped: 0, errors: [] as string[] };\n const saved = await writeState(base, H, LADDER_STATE_KEY, state, nextCursor);\n return json({\n ladder: true, scanned, due: due.length, quiet, claimed, devices: targets.length, ...sent,\n resumed_from: startedAt, cursor: nextCursor, wrapped, saved,\n }, 200);\n}\n\n/** The ladder step due for this user now, or null. Exported for tests.\n * The HIGHEST rung whose idle threshold is met (the claim then moves\n * `ladder_step` past it, so the rungs below are skipped, never sent late), and\n * only once that rung's gap since the previous nudge has passed. */\nexport function dueStep(p: PrefRow['data'], now: Date): number | null {\n if (p.reminders === false || !p.end_user || !p.last_active) return null;\n const idleH = (now.getTime() - Date.parse(p.last_active)) / 3_600_000;\n if (!Number.isFinite(idleH)) return null;\n const from = Math.max(0, Math.trunc(p.ladder_step ?? 0));\n let step: number | null = null;\n for (let r = from; r < LADDER.length; r++) if (idleH >= LADDER[r]!.afterHours) step = r;\n if (step === null) return null;\n if (step > 0 && p.last_reminded_at) {\n const sinceH = (now.getTime() - Date.parse(p.last_reminded_at)) / 3_600_000;\n const gapH = LADDER[step]!.afterHours - LADDER[step - 1]!.afterHours;\n if (Number.isFinite(sinceH) && sinceH < gapH) return null;\n }\n return step;\n}\n\n/** Is it inside the user's quiet hours [quiet_start, quiet_end) in THEIR zone?\n * The window may wrap midnight; equal or missing bounds mean none. An unknown\n * zone is evaluated in UTC. Exported for tests. */\nexport function inQuietHours(p: PrefRow['data'], now: Date): boolean {\n const start = p.quiet_start;\n const end = p.quiet_end;\n if (typeof start !== 'number' || typeof end !== 'number' || start === end) return false;\n const h = localHour(now, p.timezone);\n return start < end ? h >= start && h < end : h >= start || h < end;\n}\n\n/** One formatter per zone, kept for the isolate's life: building an Intl formatter is the\n * expensive part, and a tick can test hundreds of users in the same few zones\n * (CPU per invocation is capped \u2014 2,000 ms on a Free staging/development project; on a dev slot 50 ms unless the function declares limits.cpuMs, up to 2,000; both within a 500,000 CPU-ms daily budget). */\nconst ZONE_FMT = new Map<string, Intl.DateTimeFormat | null>();\nfunction zoneFormatter(tz: string): Intl.DateTimeFormat | null {\n if (!ZONE_FMT.has(tz)) {\n let f: Intl.DateTimeFormat | null = null;\n try { f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hourCycle: 'h23' }); } catch { /* unknown zone */ }\n if (ZONE_FMT.size < 500) ZONE_FMT.set(tz, f);\n return f;\n }\n return ZONE_FMT.get(tz)!;\n}\n\nfunction localHour(now: Date, tz: string | undefined): number {\n try {\n const part = zoneFormatter(tz || 'UTC')?.formatToParts(now).find((x) => x.type === 'hour');\n const h = Number(part?.value);\n if (Number.isInteger(h)) return h % 24;\n } catch { /* unknown zone \u2192 UTC below */ }\n return now.getUTCHours();\n}\n\nasync function readState(base: string, H: Record<string, string>, key: string): Promise<StateRow | null | 'error'> {\n const filter = encodeURIComponent(JSON.stringify({ key }));\n const res = await fetch(`${base}/v1/cms/items/push_state?filter=${filter}&limit=1`, { headers: H });\n if (!res.ok) return 'error';\n return ((await res.json()) as { data?: { items?: StateRow[] } }).data?.items?.[0] ?? null;\n}\n\nasync function writeState(\n base: string, H: Record<string, string>, key: string, state: StateRow | null, cursor: string,\n): Promise<boolean> {\n const data = { key, cursor, updated_at: new Date().toISOString() };\n const res = state\n ? await fetch(`${base}/v1/cms/items/push_state/${state.item_id}`, {\n method: 'PATCH',\n headers: { ...H, ...(state.version !== undefined ? { 'if-match': String(state.version) } : {}) },\n body: JSON.stringify({ data }),\n }).catch(() => null)\n : await fetch(`${base}/v1/cms/items/push_state`, {\n method: 'POST', headers: H, body: JSON.stringify({ status: 'published', data }),\n }).catch(() => null);\n return !!res && res.ok;\n}\n\n/** Locale match: exact ('pt-BR'), then the base language ('pt'), then default. */\nfunction pick(messages: Messages, locale: string | undefined): Copy {\n if (locale) {\n if (messages[locale]) return messages[locale]!;\n const bare = locale.split('-')[0]!;\n if (messages[bare]) return messages[bare]!;\n }\n return messages.default ?? {};\n}\n\nasync function patch(base: string, H: Record<string, string>, d: TokenRow, data: Record<string, unknown>) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, {\n method: 'PATCH', headers: H, body: JSON.stringify({ data }),\n }).catch(() => null);\n}\nasync function del(base: string, H: Record<string, string>, d: TokenRow) {\n await fetch(`${base}/v1/cms/items/push_tokens/${d.item_id}`, { method: 'DELETE', headers: H }).catch(() => null);\n}\nconst json = (o: unknown, status: number) => Response.json(o, { status });\n"
|
|
20270
20840
|
}
|
|
20271
20841
|
},
|
|
20272
20842
|
{
|
|
@@ -21040,6 +21610,9 @@ function quoteIdent2(name) {
|
|
|
21040
21610
|
if (!IDENT_RE2.test(name)) throw new Error(`unsafe identifier '${name}' \u2014 only [A-Za-z_][A-Za-z0-9_]* is readable`);
|
|
21041
21611
|
return `"${name}"`;
|
|
21042
21612
|
}
|
|
21613
|
+
function parseTimestampNoTz(text) {
|
|
21614
|
+
return /* @__PURE__ */ new Date(`${text.replace(" ", "T")}Z`);
|
|
21615
|
+
}
|
|
21043
21616
|
function redactSourceUrl(url) {
|
|
21044
21617
|
try {
|
|
21045
21618
|
const u = new URL(url);
|
|
@@ -21292,6 +21865,10 @@ var GenericPgAdapter = class {
|
|
|
21292
21865
|
// transaction-pooler (pgbouncer/supavisor) compatible
|
|
21293
21866
|
onnotice: () => {
|
|
21294
21867
|
},
|
|
21868
|
+
// timestamp-without-tz read as UTC (the session only ever reads).
|
|
21869
|
+
types: {
|
|
21870
|
+
timestampNoTz: { to: 1114, from: [1114], parse: parseTimestampNoTz, serialize: (d) => d.toISOString() }
|
|
21871
|
+
},
|
|
21295
21872
|
connection: {
|
|
21296
21873
|
application_name: "vxil-migrate",
|
|
21297
21874
|
// startup-parameter copies of the session SETs — they survive driver
|
|
@@ -21608,6 +22185,25 @@ var supabaseSql = {
|
|
|
21608
22185
|
params: after !== null ? [after] : []
|
|
21609
22186
|
};
|
|
21610
22187
|
},
|
|
22188
|
+
/** auth.identities column NAMES (GoTrue added `provider_id` in 2023; older
|
|
22189
|
+
* projects kept the provider subject in `id`). */
|
|
22190
|
+
identitiesColumns: () => ({
|
|
22191
|
+
text: `
|
|
22192
|
+
SELECT a.attname AS column_name
|
|
22193
|
+
FROM pg_attribute a
|
|
22194
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
22195
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
22196
|
+
WHERE n.nspname = 'auth' AND c.relname = 'identities'
|
|
22197
|
+
AND a.attnum > 0 AND NOT a.attisdropped`,
|
|
22198
|
+
params: []
|
|
22199
|
+
}),
|
|
22200
|
+
/** MIGRATE-OAUTH-IDENTITIES: the social links of ONE credentials page —
|
|
22201
|
+
* provider + the provider's subject only (never identity_data, never a
|
|
22202
|
+
* token), the four providers vxil links by subject. */
|
|
22203
|
+
identitiesFor: (userIds, subjectColumn) => ({
|
|
22204
|
+
text: `SELECT user_id::text AS user_id, provider, ${subjectColumn}::text AS subject FROM auth.identities WHERE user_id = ANY($1::uuid[]) AND provider IN ('google', 'apple', 'github', 'facebook') ORDER BY user_id, provider, ${subjectColumn}`,
|
|
22205
|
+
params: [userIds]
|
|
22206
|
+
}),
|
|
21611
22207
|
/** does this database carry the Storage schema? */
|
|
21612
22208
|
storageProbe: () => ({
|
|
21613
22209
|
text: `SELECT to_regclass('storage.buckets') IS NOT NULL AS present`,
|
|
@@ -21700,17 +22296,36 @@ var SupabaseAdapter = class extends GenericPgAdapter {
|
|
|
21700
22296
|
}
|
|
21701
22297
|
/** Credential pages (supabaseSql.credentialsPage). The rows hold bcrypt
|
|
21702
22298
|
* hashes: never logged, never written anywhere but the import request. */
|
|
21703
|
-
async *readCredentials(cursor) {
|
|
22299
|
+
async *readCredentials(cursor, opts = {}) {
|
|
21704
22300
|
const source = await this.introspect();
|
|
21705
22301
|
if (!source.authUsers?.present) throw new Error("auth.users not present on this source");
|
|
21706
22302
|
const cols = source.authUsers.columns ?? [];
|
|
21707
22303
|
if (!cols.includes("encrypted_password")) throw new Error("auth.users has no encrypted_password column \u2014 nothing to import");
|
|
22304
|
+
let subjectColumn = null;
|
|
22305
|
+
if (opts.identities) {
|
|
22306
|
+
const icols = (await this.q(supabaseSql.identitiesColumns())).map((r) => r.column_name);
|
|
22307
|
+
if (!icols.includes("user_id") || !icols.includes("provider")) {
|
|
22308
|
+
throw new Error("auth.identities not found on this source \u2014 drop --with-oauth-identities");
|
|
22309
|
+
}
|
|
22310
|
+
subjectColumn = icols.includes("provider_id") ? "provider_id" : "id";
|
|
22311
|
+
}
|
|
21708
22312
|
let after = cursor !== void 0 && cursor !== "" ? cursor : null;
|
|
21709
22313
|
for (; ; ) {
|
|
21710
22314
|
const rows = await this.q(supabaseSql.credentialsPage(after, this.batchSize, cols.includes("deleted_at")));
|
|
22315
|
+
const links = /* @__PURE__ */ new Map();
|
|
22316
|
+
if (subjectColumn !== null && rows.length > 0) {
|
|
22317
|
+
for (const r of await this.q(supabaseSql.identitiesFor(rows.map((x) => String(x.id)), subjectColumn))) {
|
|
22318
|
+
if (typeof r.subject !== "string" || r.subject === "") continue;
|
|
22319
|
+
const list = links.get(String(r.user_id)) ?? [];
|
|
22320
|
+
list.push({ provider: String(r.provider), subject: r.subject });
|
|
22321
|
+
links.set(String(r.user_id), list);
|
|
22322
|
+
}
|
|
22323
|
+
}
|
|
21711
22324
|
const out = rows.map((r) => {
|
|
21712
22325
|
const h = typeof r.encrypted_password === "string" && r.encrypted_password.length > 0 ? r.encrypted_password : null;
|
|
21713
|
-
|
|
22326
|
+
const row2 = { id: String(r.id), password_hash: h, email_verified: r.email_verified === true };
|
|
22327
|
+
if (subjectColumn !== null) row2.identities = links.get(String(r.id)) ?? [];
|
|
22328
|
+
return row2;
|
|
21714
22329
|
});
|
|
21715
22330
|
const next = rows.length === this.batchSize ? String(rows[rows.length - 1].id) : null;
|
|
21716
22331
|
yield { rows: out, nextCursor: next };
|
|
@@ -22422,6 +23037,11 @@ async function runApply(apply, sel = "prod", opts = {}) {
|
|
|
22422
23037
|
report.errors++;
|
|
22423
23038
|
}
|
|
22424
23039
|
};
|
|
23040
|
+
{
|
|
23041
|
+
const drain = cfg.features?.functions?.logDrain;
|
|
23042
|
+
const drainErr = drain === void 0 ? null : logDrainShapeError(drain);
|
|
23043
|
+
if (drainErr) throw new Error(`features.functions.logDrain: ${drainErr} \u2014 nothing was ${apply ? "pushed" : "planned"}`);
|
|
23044
|
+
}
|
|
22425
23045
|
let cmsApi = api;
|
|
22426
23046
|
if (apply) {
|
|
22427
23047
|
const enabledAt = await enableCmsBeforeSchema({ api, cfg });
|
|
@@ -22453,7 +23073,6 @@ cms: enabled (v${enabledAt}) before its schema \u2014 the declared cms config fo
|
|
|
22453
23073
|
compared.collections += Object.keys(cfg.cms.collections).length;
|
|
22454
23074
|
});
|
|
22455
23075
|
const { functions: _fnFeature, ...configFeatures } = cfg.features;
|
|
22456
|
-
void _fnFeature;
|
|
22457
23076
|
const serverApiState = {};
|
|
22458
23077
|
await runSection("config", async () => {
|
|
22459
23078
|
if (!Object.keys(configFeatures).length) return;
|
|
@@ -22470,6 +23089,8 @@ ${r.feature}:`);
|
|
|
22470
23089
|
}
|
|
22471
23090
|
console.log(`
|
|
22472
23091
|
${r.feature} (remote v${r.version}):`);
|
|
23092
|
+
const activeOrgWarn = activeOrgEnableWarning(r.feature, r.changes);
|
|
23093
|
+
if (activeOrgWarn) console.error(`vxil: warning \u2014 ${activeOrgWarn}`);
|
|
22473
23094
|
const { kept, ignored } = partitionConfigChanges(r.feature, r.changes, ignore);
|
|
22474
23095
|
console.log(formatDiff(kept));
|
|
22475
23096
|
if (ignored.length) console.log(` (${ignored.length} ignored: ${ignored.map((c) => c.path).join(", ")})`);
|
|
@@ -22482,6 +23103,30 @@ ${r.feature} (remote v${r.version}):`);
|
|
|
22482
23103
|
compared.features++;
|
|
22483
23104
|
}
|
|
22484
23105
|
});
|
|
23106
|
+
await runSection("log-drain", async () => {
|
|
23107
|
+
const r = await planOrPushLogDrain({ api, fnFeature: _fnFeature, apply });
|
|
23108
|
+
if (!r) return;
|
|
23109
|
+
if (r.remoteUnavailable) {
|
|
23110
|
+
console.log("\nfunctions.logDrain:");
|
|
23111
|
+
notCompared(r.remoteUnavailable, "needs features:read or admin");
|
|
23112
|
+
return;
|
|
23113
|
+
}
|
|
23114
|
+
const show = (d) => {
|
|
23115
|
+
const url = d?.url;
|
|
23116
|
+
if (typeof url !== "string") return "(none)";
|
|
23117
|
+
try {
|
|
23118
|
+
return new URL(url).host;
|
|
23119
|
+
} catch {
|
|
23120
|
+
return "(invalid url)";
|
|
23121
|
+
}
|
|
23122
|
+
};
|
|
23123
|
+
console.log(`
|
|
23124
|
+
functions.logDrain (remote v${r.version}):`);
|
|
23125
|
+
console.log(r.changed ? ` ~ logDrain: ${show(r.from)} \u2192 ${show(r.to)}` : " (no changes)");
|
|
23126
|
+
if (r.applied) console.log(` \u2713 applied as v${r.applied}`);
|
|
23127
|
+
if (r.changed) count("config:functions.logDrain");
|
|
23128
|
+
compared.features++;
|
|
23129
|
+
});
|
|
22485
23130
|
const strictApiState = hasFlag("strict-api-state");
|
|
22486
23131
|
const declaredApiStateKeys = [];
|
|
22487
23132
|
await runSection("api-state", async () => {
|
|
@@ -22530,7 +23175,8 @@ ${r.feature}.${r.datum} (declared in config):`);
|
|
|
22530
23175
|
if (!apply) count(key);
|
|
22531
23176
|
}
|
|
22532
23177
|
const n = r.summary.created.length + r.summary.updated.length + r.summary.recreated.length + r.summary.unchanged.length + r.summary.undeclared.length + r.summary.deleted.length + r.summary.pending_destructive.length + r.summary.ambiguous.length;
|
|
22533
|
-
|
|
23178
|
+
const ck = r.datum === "collections" ? "searchCollections" : r.datum;
|
|
23179
|
+
compared[ck] = (compared[ck] ?? 0) + n;
|
|
22534
23180
|
}
|
|
22535
23181
|
});
|
|
22536
23182
|
await runSection("functions", async () => {
|
|
@@ -24234,7 +24880,7 @@ ${formatSummary(state)}`);
|
|
|
24234
24880
|
case "architect": {
|
|
24235
24881
|
const description = positional(0).join(" ").trim();
|
|
24236
24882
|
if (!description) fail('usage: vxil architect "<describe your app>" [--name <n>] [--out vxil.config.ts] [--brief <file.md>|--no-brief]');
|
|
24237
|
-
const { api } = requireApi(selector());
|
|
24883
|
+
const { api, baseUrl } = requireApi(selector());
|
|
24238
24884
|
const nameFlag = flag("name");
|
|
24239
24885
|
const noBrief = hasFlag("no-brief");
|
|
24240
24886
|
const briefOut = flag("brief");
|
|
@@ -24275,7 +24921,7 @@ ${plan.rationale}`);
|
|
|
24275
24921
|
if (briefOut && !plan.brief) {
|
|
24276
24922
|
fail(`--brief ${briefOut}: the server returned no brief \u2014 nothing written`);
|
|
24277
24923
|
} else if (briefOut) {
|
|
24278
|
-
writeFileSync6(briefOut, renderBriefMarkdown(plan.brief, plan.app ?? {}));
|
|
24924
|
+
writeFileSync6(briefOut, renderBriefMarkdown(plan.brief, plan.app ?? {}, { site: briefSiteFor(baseUrl) }));
|
|
24279
24925
|
console.log(`\u2713 wrote ${briefOut}`);
|
|
24280
24926
|
} else if (plan.brief) {
|
|
24281
24927
|
console.log(" (run again with --brief brief.md to write the full brief as markdown)");
|
|
@@ -24344,6 +24990,16 @@ ${plan.rationale}`);
|
|
|
24344
24990
|
if (pulled.note) pullNotes.push(pulled.note);
|
|
24345
24991
|
}
|
|
24346
24992
|
}
|
|
24993
|
+
if (features.includes("vector-search")) {
|
|
24994
|
+
const r = await api("GET", "/v1/search/collections");
|
|
24995
|
+
if (r.status === 200 && featBlocks["vector-search"]) {
|
|
24996
|
+
const block = featBlocks["vector-search"];
|
|
24997
|
+
const pulled = pulledSearchCollections(r.body.data?.collections ?? []);
|
|
24998
|
+
if (pulled.collections) block.collections = pulled.collections;
|
|
24999
|
+
else delete block.collections;
|
|
25000
|
+
if (pulled.note) pullNotes.push(pulled.note);
|
|
25001
|
+
}
|
|
25002
|
+
}
|
|
24347
25003
|
if (features.includes("ai")) {
|
|
24348
25004
|
const block = featBlocks.ai;
|
|
24349
25005
|
delete block.templates;
|
|
@@ -24895,7 +25551,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
24895
25551
|
architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
|
|
24896
25552
|
plan [--explain] \xB7 push [--dev|--target <name>|--prod] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] \xB7 pull \xB7 gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>]
|
|
24897
25553
|
push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
|
|
24898
|
-
diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets; declared policies/subscriptions/templates/roles compared to live, an undeclared live row informational unless --strict-api-state)
|
|
25554
|
+
diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] \u2014 CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error (an unreadable remote is 2; secrets by name only, unstored refs informational unless --strict-secrets; declared policies/subscriptions/templates/roles/search collections compared to live, an undeclared live row informational unless --strict-api-state)
|
|
24899
25555
|
doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash + keys with last-used (stored session)
|
|
24900
25556
|
versions <feature> \xB7 rollback <feature> --to <v>
|
|
24901
25557
|
config draft show|save [--file <f>]|publish|discard <feature> \xB7 config draft duplicate <feature> --from <v> \u2014 stage a config change without making it live
|
|
@@ -25767,6 +26423,9 @@ function migrateUsage() {
|
|
|
25767
26423
|
rehashed at each user's first sign-in) \xB7 [--oidc-issuer https://<ref>.supabase.co/auth/v1]
|
|
25768
26424
|
link users for the installed-app session hand-over \xB7 [--file-links shared-link] stored
|
|
25769
26425
|
public file URLs become stable shared links instead of object ids
|
|
26426
|
+
\xB7 [--with-oauth-identities] carry the Google/Apple/GitHub/Facebook sign-in links (needs
|
|
26427
|
+
--with-password-hashes in the same run) \xB7 [--keep-timestamps] keep each row's source
|
|
26428
|
+
created_at / updated_at (cms creates; server key only)
|
|
25770
26429
|
verify reconcile source vs target counts + re-links + restate the residuals (exit 1 on deltas)
|
|
25771
26430
|
payments --from-provider stripe|paddle|paypal|revenuecat (--customers <file.csv> | --all) [--dry-run]
|
|
25772
26431
|
backfill SUBSCRIPTIONS through the server sync leg (the project's own provider keys; resumable via
|
|
@@ -25871,6 +26530,8 @@ ${migrateUsage()}`);
|
|
|
25871
26530
|
resume: hasFlag("resume"),
|
|
25872
26531
|
...hasFlag("with-password-hashes") ? { withPasswordHashes: true } : {},
|
|
25873
26532
|
...oidcIssuer !== void 0 ? { oidcIssuer } : {},
|
|
26533
|
+
...hasFlag("with-oauth-identities") ? { withOauthIdentities: true } : {},
|
|
26534
|
+
...hasFlag("keep-timestamps") ? { keepTimestamps: true } : {},
|
|
25874
26535
|
...fileLinks !== void 0 ? { fileLinks } : {},
|
|
25875
26536
|
...only?.length ? { only } : {},
|
|
25876
26537
|
...hasFlag("assume-schema") ? { assumeSchema: true } : {},
|