@vxil/cli 0.26.3 → 0.27.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/README.md +1 -1
- package/dist/_vxil-feature-configs.d.ts +6 -0
- package/dist/vxil-main.js +141 -23
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Verbs: `init templates quickstart try login link keys account logout invites mem
|
|
|
48
48
|
|
|
49
49
|
- `vxil plan --explain` — the config plan with every leaf tagged *declared* or *default*.
|
|
50
50
|
- `vxil push [--skip-functions]` — apply schema → config → functions; a project below the functions plan is stopped *before anything is pushed*, and `--skip-functions` pushes the rest.
|
|
51
|
-
- `vxil diff [--against <target>] [--ignore <k,…>] [--strict-secrets]` — the CI drift gate; secrets compared by name only, and a referenced-but-unstored name is informational unless `--strict-secrets`.
|
|
51
|
+
- `vxil diff [--against <target>] [--ignore <k,…>] [--strict-secrets] [--strict-runtime]` — the CI drift gate; secrets compared by name only, and a referenced-but-unstored name is informational unless `--strict-secrets`. A function whose only difference is a platform setting — the functions runtime (compatibility date/flags) or the plan's CPU limit — is listed with `reason: runtime <old> → <new>` / `reason: cpu-limit <old> ms → <new> ms` and is informational under the gate (`--exit-code` / `--against`: exit 0) unless `--strict-runtime` — a plain `vxil diff` still counts it; `vxil plan` shows the same reason, and `vxil push` redeploys it under the new settings.
|
|
52
52
|
- `vxil doctor` — preflight: secrets referenced-vs-stored (advisory, names only) plus a served / published / local hash verdict per function. Under `--json` stdout is one JSON document; notices go to stderr.
|
|
53
53
|
- `vxil link <slug>` — reuses the key already stored for the slug on this machine; `--mint` for a fresh one, `--key` to hand one in.
|
|
54
54
|
- `vxil keys mint --name <name> --scopes <a,b,c> [--env live|test] [--store]` — mint a **narrow** key for a server (scopes validated locally; `admin` refused); the key prints **once** to stdout (`--json` → `{ api_key, key_id, scopes }`) and is never stored unless `--store`. `vxil keys list` shows names, ids, scopes, created and last-used (never secrets); `vxil keys revoke <key_id> [--yes]` asks you to type the id, and refuses the key the CLI itself is using unless `--yes`. Needs a dashboard session (`vxil login`, or it prompts at a terminal; a piped / `--json` run fails with that remedy instead — pass `--email`/`--password` to script it, which never stores the session).
|
|
@@ -234,6 +234,7 @@ declare const OidcProviderSchema: import("@sinclair/typebox").TObject<{
|
|
|
234
234
|
}>>;
|
|
235
235
|
allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
236
236
|
autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
237
|
+
linkOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
237
238
|
}>;
|
|
238
239
|
export declare const REDIRECT_ORIGIN_PATTERN = "^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$";
|
|
239
240
|
/** THE scheme rule for a redirect/return URL: https anywhere, http on loopback
|
|
@@ -292,6 +293,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
292
293
|
}>>;
|
|
293
294
|
allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
294
295
|
autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
296
|
+
linkOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
295
297
|
}>>;
|
|
296
298
|
}>>;
|
|
297
299
|
session: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
@@ -330,11 +332,13 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
330
332
|
maxFailures: import("@sinclair/typebox").TInteger;
|
|
331
333
|
windowMinutes: import("@sinclair/typebox").TInteger;
|
|
332
334
|
lockMinutes: import("@sinclair/typebox").TInteger;
|
|
335
|
+
countOtp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
333
336
|
}>>;
|
|
334
337
|
breachedPasswords: import("@sinclair/typebox").TBoolean;
|
|
335
338
|
captchaSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
336
339
|
captchaOnReset: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
337
340
|
emailChangeStepUp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
341
|
+
canarySessions: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
338
342
|
allowedRedirectOrigins: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
339
343
|
inviteOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
340
344
|
verifyLinkEvicts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
@@ -798,6 +802,8 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
798
802
|
slackHours: import("@sinclair/typebox").TInteger;
|
|
799
803
|
}>>;
|
|
800
804
|
unmappedProduct: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"error">, import("@sinclair/typebox").TLiteral<"ignore">]>>;
|
|
805
|
+
deferDowngrades: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
806
|
+
subPeriodGrants: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
801
807
|
}>>;
|
|
802
808
|
}>;
|
|
803
809
|
export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
|
package/dist/vxil-main.js
CHANGED
|
@@ -3888,7 +3888,13 @@ var OidcProviderSchema = Type.Object({
|
|
|
3888
3888
|
// default true: an identity whose VERIFIED email matches an existing user is
|
|
3889
3889
|
// linked to it (the shipped social sign-in rule). false: link only by the stable
|
|
3890
3890
|
// (issuer, sub) anchor; a matching email that is not yet linked → 409.
|
|
3891
|
-
autoLink: Type.Optional(Type.Boolean())
|
|
3891
|
+
autoLink: Type.Optional(Type.Boolean()),
|
|
3892
|
+
// AUTH-OIDC-LINK-ONLY (2026-10-13), default false: true ⇒ this issuer only
|
|
3893
|
+
// signs in / links an EXISTING user (matched by the rule above: the stable
|
|
3894
|
+
// (issuer, sub) link, else — autoLink on — a verified email); an identity
|
|
3895
|
+
// matching none is refused 403 oidc_link_only (no user created, no guest
|
|
3896
|
+
// promoted). Staff / SSO-style sign-in without open sign-up.
|
|
3897
|
+
linkOnly: Type.Optional(Type.Boolean())
|
|
3892
3898
|
});
|
|
3893
3899
|
var REDIRECT_ORIGIN_PATTERN = "^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$";
|
|
3894
3900
|
var AuthConfigSchema = Type.Object({
|
|
@@ -4053,6 +4059,7 @@ var AuthConfigSchema = Type.Object({
|
|
|
4053
4059
|
// • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
|
|
4054
4060
|
// sign-in + OTP verify (429 account_locked + Retry-After); a bounded
|
|
4055
4061
|
// stored counter survives restarts.
|
|
4062
|
+
// lockout.countOtp: false takes OTP verify out of it (see below).
|
|
4056
4063
|
// • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
|
|
4057
4064
|
// chars leave the worker, never the password) at sign-up / reset-confirm;
|
|
4058
4065
|
// fail-OPEN on network error → 422 password_breached.
|
|
@@ -4072,7 +4079,14 @@ var AuthConfigSchema = Type.Object({
|
|
|
4072
4079
|
lockout: Type.Optional(Type.Object({
|
|
4073
4080
|
maxFailures: Type.Integer({ default: 10, minimum: 3, maximum: 100 }),
|
|
4074
4081
|
windowMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
|
|
4075
|
-
lockMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 })
|
|
4082
|
+
lockMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
|
|
4083
|
+
// AUTH-LOCKOUT-OTP-DOS (2026-10-13): false ⇒ wrong OTP verifies neither
|
|
4084
|
+
// charge nor are refused by the identifier lockout (it is keyed on the
|
|
4085
|
+
// address alone, so anyone could lock an OTP user out); OTP-only apps
|
|
4086
|
+
// then rely on otp.maxAttempts + resendCooldownSec. Optional, NO
|
|
4087
|
+
// default (absent = true = the pre-flag behavior) — existing manifests
|
|
4088
|
+
// fold byte-identically.
|
|
4089
|
+
countOtp: Type.Optional(Type.Boolean())
|
|
4076
4090
|
})),
|
|
4077
4091
|
breachedPasswords: Type.Boolean({ default: false }),
|
|
4078
4092
|
captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
|
|
@@ -4085,6 +4099,12 @@ var AuthConfigSchema = Type.Object({
|
|
|
4085
4099
|
// Optional, NO default (absent = false) — existing manifests fold unchanged.
|
|
4086
4100
|
// Requires otp.enabled (cross-field rule in validateFeatureConfig).
|
|
4087
4101
|
emailChangeStepUp: Type.Optional(Type.Boolean()),
|
|
4102
|
+
// CANARY-SESSION (2026-10-13): the TENANT opt-in for server-key canary
|
|
4103
|
+
// sessions (PUT /v1/auth/users/{id}/canary + POST …/canary-session). Off ⇒
|
|
4104
|
+
// both answer 403 canary_sessions_disabled (clearing a flag still works),
|
|
4105
|
+
// so holding auth:write alone never lets a key sign in as a user. Optional,
|
|
4106
|
+
// NO default (absent = false) — existing manifests fold unchanged.
|
|
4107
|
+
canarySessions: Type.Optional(Type.Boolean()),
|
|
4088
4108
|
allowedRedirectOrigins: Type.Optional(Type.Array(
|
|
4089
4109
|
Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
|
|
4090
4110
|
{ maxItems: 32 }
|
|
@@ -5011,7 +5031,29 @@ var PaymentsConfigSchema = Type.Object({
|
|
|
5011
5031
|
// `ledger` bag and make `vxil push --dry-run` / `vxil diff` report drift for
|
|
5012
5032
|
// every payments tenant. The READER owns the default
|
|
5013
5033
|
// (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
|
|
5014
|
-
unmappedProduct: Type.Optional(Type.Union([Type.Literal("error"), Type.Literal("ignore")]))
|
|
5034
|
+
unmappedProduct: Type.Optional(Type.Union([Type.Literal("error"), Type.Literal("ignore")])),
|
|
5035
|
+
// (2026-10-13 PADDLE-DEFER-DOWNGRADE) Opt-in, PADDLE ONLY. For a tenant
|
|
5036
|
+
// whose app changes plans down only with proration_billing_mode
|
|
5037
|
+
// `full_next_billing_period`: a Paddle subscription event that keeps the
|
|
5038
|
+
// row's current_period_start and names a LOWER-ranked tierMap tier keeps
|
|
5039
|
+
// the paid tier and notes `metadata.pending_product_change`; the next
|
|
5040
|
+
// period's event applies the new tier (the RevenueCat PRODUCT_CHANGE rule).
|
|
5041
|
+
// Never on by default: a `prorated_immediately` downgrade credits the
|
|
5042
|
+
// unused time at once, so deferring it would hand out the higher tier for
|
|
5043
|
+
// free. Not Stripe (period-end changes there are subscription schedules).
|
|
5044
|
+
// No schema default (Value.Default would materialize it into every
|
|
5045
|
+
// manifest); the reader treats absent as false. Paddle is required below.
|
|
5046
|
+
deferDowngrades: Type.Optional(Type.Boolean()),
|
|
5047
|
+
// (2026-10-13 YEARLY-SUBPERIOD-GRANTS) Opt-in. A `monthly`
|
|
5048
|
+
// tierMap grant on an ACTIVE paid subscription whose billing period is
|
|
5049
|
+
// longer than a month (a yearly or quarterly plan) mints once per
|
|
5050
|
+
// anniversary month instead of once per period: any fold mints the month
|
|
5051
|
+
// that is due and the nightly reconcile sweep folds the rows no provider
|
|
5052
|
+
// event touches. Off by default: on, a tenant already granting monthly
|
|
5053
|
+
// credits from a cron function would grant them twice, and a tenant that
|
|
5054
|
+
// sized a yearly plan's `amount` for the year would mint it every month.
|
|
5055
|
+
// No schema default; the reader treats absent as false.
|
|
5056
|
+
subPeriodGrants: Type.Optional(Type.Boolean())
|
|
5015
5057
|
}))
|
|
5016
5058
|
// NB: the former `webhooks.forwardToTenantUrl` leaf
|
|
5017
5059
|
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
@@ -5542,9 +5584,9 @@ function lowerAllTriggerBindings(def, name) {
|
|
|
5542
5584
|
return bindings;
|
|
5543
5585
|
}
|
|
5544
5586
|
var CONFIG_FILENAMES = ["vxil.config.ts", "vxil.config.mjs", "vxil.config.js"];
|
|
5545
|
-
var VXIL_CONFIG_PKG_VERSION = "0.
|
|
5546
|
-
var VXIL_SDK_PKG_VERSION = "0.
|
|
5547
|
-
var VXIL_CLI_PKG_VERSION = "0.
|
|
5587
|
+
var VXIL_CONFIG_PKG_VERSION = "0.20.0";
|
|
5588
|
+
var VXIL_SDK_PKG_VERSION = "0.27.0";
|
|
5589
|
+
var VXIL_CLI_PKG_VERSION = "0.27.0";
|
|
5548
5590
|
function ensureScaffoldPackageJson(cwd, opts = {}) {
|
|
5549
5591
|
const file = resolve(cwd, "package.json");
|
|
5550
5592
|
const wanted = {
|
|
@@ -9017,6 +9059,9 @@ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync
|
|
|
9017
9059
|
import { homedir } from "node:os";
|
|
9018
9060
|
import { dirname as dirname2, resolve as resolve2, relative, isAbsolute, sep } from "node:path";
|
|
9019
9061
|
import { createHash } from "node:crypto";
|
|
9062
|
+
function describeRuntime(r) {
|
|
9063
|
+
return `${r.compatibilityDate}${r.compatibilityFlags.length ? ` [${r.compatibilityFlags.join(",")}]` : ""}`;
|
|
9064
|
+
}
|
|
9020
9065
|
function describeBinding(b2) {
|
|
9021
9066
|
const parts = [b2.kind];
|
|
9022
9067
|
if (b2.kind === "cron") parts.push(b2.schedule ? `'${b2.schedule}'` : "(no schedule)");
|
|
@@ -9344,16 +9389,36 @@ async function planFunctions({ api, functions, cwd, apply, onNote, remoteOnly =
|
|
|
9344
9389
|
const sizeWarnings = headroom ? [{ fn: name, message: headroom }] : [];
|
|
9345
9390
|
const retryN = bindings.find((b2) => b2.retry)?.retry?.maxAttempts;
|
|
9346
9391
|
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" : ""}]`;
|
|
9347
|
-
|
|
9392
|
+
const unchanged = (ignorePlatform) => {
|
|
9393
|
+
if (remoteFn !== void 0 && remoteFn.scriptRef.endsWith(`-${ignorePlatform ? sourceSha12(source, remoteFn.runtime) : sha}`) && sameSecretSet(remoteFn.secrets, secrets) && (ignorePlatform || sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime)) && (ignorePlatform || 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) {
|
|
9394
|
+
return true;
|
|
9395
|
+
}
|
|
9396
|
+
return false;
|
|
9397
|
+
};
|
|
9398
|
+
if (unchanged(false)) {
|
|
9348
9399
|
return { change: { name, kind: "unchanged", detail: label }, applied: 0, cmsHooks: null, webhooks: null, warnings: sizeWarnings, hook: null };
|
|
9349
9400
|
}
|
|
9401
|
+
const reasons = [];
|
|
9402
|
+
if (remoteFn) {
|
|
9403
|
+
if (!sameFunctionsRuntime(remoteFn.runtime, read.deployRuntime)) {
|
|
9404
|
+
reasons.push({ kind: "runtime", from: describeRuntime(remoteFn.runtime), to: describeRuntime(read.deployRuntime) });
|
|
9405
|
+
}
|
|
9406
|
+
const from = recordedScriptCpuMs(remoteFn.runtime);
|
|
9407
|
+
const to = read.cpuMsCeiling === null ? from : planScriptCpuMs(remoteFn.runtime, limits?.cpuMs, read.cpuMsCeiling, read.cpuMsDefault ?? read.cpuMsCeiling);
|
|
9408
|
+
if (from !== to && stableStringify(remoteFn.limits ?? null) === stableStringify(limits ?? null)) {
|
|
9409
|
+
reasons.push({ kind: "cpu-limit", from, to });
|
|
9410
|
+
}
|
|
9411
|
+
}
|
|
9412
|
+
const platformOnly = reasons.length > 0 && unchanged(true);
|
|
9350
9413
|
const bindingChanges = remoteFn !== void 0 ? diffBindings(remoteFn.bindings, bindings) : null;
|
|
9351
9414
|
const change = {
|
|
9352
9415
|
name,
|
|
9353
9416
|
kind: remoteFn === void 0 ? "deploy-new" : "deploy-update",
|
|
9354
9417
|
detail: label,
|
|
9355
9418
|
bindings: bindings.map(describeBinding),
|
|
9356
|
-
...bindingChanges ? { bindingChanges } : {}
|
|
9419
|
+
...bindingChanges ? { bindingChanges } : {},
|
|
9420
|
+
...reasons.length ? { reasons } : {},
|
|
9421
|
+
...platformOnly ? { platformOnly: true } : {}
|
|
9357
9422
|
};
|
|
9358
9423
|
if (apply) {
|
|
9359
9424
|
const body = { source, trigger: triggerKind, secrets };
|
|
@@ -9441,6 +9506,12 @@ function formatFnChanges(changes) {
|
|
|
9441
9506
|
const out = [];
|
|
9442
9507
|
for (const c of actionable) {
|
|
9443
9508
|
out.push(` ${mark(c.kind)} ${c.detail}`);
|
|
9509
|
+
for (const r of c.reasons ?? []) {
|
|
9510
|
+
out.push(r.kind === "runtime" ? ` reason: runtime ${r.from} \u2192 ${r.to}` : ` reason: cpu-limit ${r.from} ms \u2192 ${r.to} ms`);
|
|
9511
|
+
}
|
|
9512
|
+
if (c.platformOnly) {
|
|
9513
|
+
out.push(" (platform update only \u2014 your function is unchanged; `vxil push` redeploys it under the new settings; under `vxil diff --exit-code` / `--against` it is a notice, not drift, unless --strict-runtime)");
|
|
9514
|
+
}
|
|
9444
9515
|
if (c.bindingChanges) {
|
|
9445
9516
|
for (const b2 of c.bindingChanges.removed) out.push(` - binding ${b2}`);
|
|
9446
9517
|
for (const b2 of c.bindingChanges.added) out.push(` + binding ${b2}`);
|
|
@@ -13393,7 +13464,7 @@ var TOOLS = [
|
|
|
13393
13464
|
{
|
|
13394
13465
|
name: "jobs_get_run",
|
|
13395
13466
|
feature: "jobs",
|
|
13396
|
-
description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null), concurrency_key / concurrency_limit (null when unkeyed) and, on a generation run, provider_ref (the provider job id, with completion.ref_path) and mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written; status_written says whether the record holds the status word \u2014 or null; a later clean completed/failed write clears it into last_mirror_error, with mirror_ok_at saying when). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
|
|
13467
|
+
description: "Read ONE run by run_id: the row jobs_list_runs summarises plus payload_json (your own jobs; a platform-enqueued run reads { redacted: true }), any generation_* fields, result (what the run completed with: its signed callback's result, or a generation's settled value; null when nothing reported one), progress (the latest { progress 0..100, stage, message, at } report, or null), concurrency_key / concurrency_limit (null when unkeyed) and, on a generation run, provider_ref (the provider job id, with completion.ref_path), attempts (each provider START call: attempt, started_at, ms, http_status, error_class \u2014 the newest 10) and mirror_error (the last status_mirror write the target refused \u2014 e.g. a callback key the collection does not declare: the status word was retried alone and fields_dropped lists what was not written; status_written says whether the record holds the status word \u2014 or null; a later clean completed/failed write clears it into last_mirror_error, with mirror_ok_at saying when). Pass wait (1..25 seconds) to WAIT FOR THIS RUN: the read holds until the run is terminal (succeeded | failed | dead | cancelled) or the deadline passes, then returns the current row \u2014 on a timeout (or when the per-tenant wait budget of 60 held reads a minute declined to hold) state is still non-terminal, so check it and call again. Use it right after jobs_enqueue or an async function invoke instead of polling jobs_list_runs.",
|
|
13397
13468
|
inputSchema: {
|
|
13398
13469
|
type: "object",
|
|
13399
13470
|
properties: {
|
|
@@ -14742,7 +14813,7 @@ var TOOLS = [
|
|
|
14742
14813
|
{
|
|
14743
14814
|
name: "ai_generation_get",
|
|
14744
14815
|
feature: "ai",
|
|
14745
|
-
description: "Read a generation back: pass generation_id for one (status pending|processing|completed|failed, usage, run_id, and for a job-lane generation the settled answer in result.text, kept 30 days), or correlation_id to find yours by your own handle (newest first, at most 20; each with result:null \u2014 call again with that generation_id for its text) \u2014 the recovery read after a timed-out or lost job answer. result_state says stored | purged | not_recorded (sync and stream generations return their answer directly and record none).",
|
|
14816
|
+
description: "Read a generation back: pass generation_id for one (status pending|processing|completed|failed, usage, run_id, and for a job-lane generation the settled answer in result.text, kept 30 days), or correlation_id to find yours by your own handle (newest first, at most 20; each with result:null \u2014 call again with that generation_id for its text) \u2014 the recovery read after a timed-out or lost job answer. result_state says stored | purged | not_recorded (sync and stream generations return their answer directly and record none). attempts lists a job-lane generation's model calls (attempt, started_at, ms, the upstream http_status, error_class \u2014 the newest 10), so a retried or slow provider call is visible.",
|
|
14746
14817
|
inputSchema: {
|
|
14747
14818
|
type: "object",
|
|
14748
14819
|
properties: {
|
|
@@ -15514,6 +15585,21 @@ function unstoredSecretVerdict(opts) {
|
|
|
15514
15585
|
if (opts.ignored) return "ignored";
|
|
15515
15586
|
return opts.strict ? "drift" : "informational";
|
|
15516
15587
|
}
|
|
15588
|
+
function platformOnlyFnVerdict(opts) {
|
|
15589
|
+
if (opts.ignored) return "ignored";
|
|
15590
|
+
return opts.gate && !opts.strict ? "informational" : "drift";
|
|
15591
|
+
}
|
|
15592
|
+
function partitionFnDrift(changes, opts) {
|
|
15593
|
+
const out = { count: [], informational: [] };
|
|
15594
|
+
for (const c of changes) {
|
|
15595
|
+
if (c.kind === "unchanged") continue;
|
|
15596
|
+
const key = `function:${c.name}`;
|
|
15597
|
+
if (c.platformOnly && platformOnlyFnVerdict({ gate: opts.gate, strict: opts.strict, ignored: isIgnored(key, opts.ignore) }) === "informational") {
|
|
15598
|
+
out.informational.push(key);
|
|
15599
|
+
} else out.count.push(key);
|
|
15600
|
+
}
|
|
15601
|
+
return out;
|
|
15602
|
+
}
|
|
15517
15603
|
function driftExitCode(r) {
|
|
15518
15604
|
if (r.errors > 0) return 2;
|
|
15519
15605
|
return r.drift > 0 ? 1 : 0;
|
|
@@ -16649,7 +16735,7 @@ var VERB_USAGE = {
|
|
|
16649
16735
|
listen: "usage: vxil listen --forward-to <url> [--dev] [--source <id>] [--events <prefix,\u2026>] [--replay-last <n>] [--interval <s>] [--show-runs] [--raw] \u2014 forward inbound webhook events to a local server",
|
|
16650
16736
|
mcp: "usage: vxil mcp install [--client cursor|claude|vscode] [--project] [--print] [--key <api_key>] [--scopes a,b] [--name <server>]",
|
|
16651
16737
|
plan: "usage: vxil plan [--dev|--target <name>] [--explain] [--skip-functions] \u2014 what `vxil push` would change: cms schema, feature config, declared API state, functions (a deployed function the config no longer declares shows as `-`); writes nothing",
|
|
16652
|
-
diff: "usage: vxil diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-api-state] [--api-state] [--json] \u2014 the CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error",
|
|
16738
|
+
diff: "usage: vxil diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-runtime] [--strict-api-state] [--api-state] [--json] \u2014 the CI gate: exit 0 no drift \xB7 1 drift \xB7 2 error",
|
|
16653
16739
|
push: "usage: vxil push [--dev|--target <name>|--prod] [--allow-destructive] [--strict] [--server [--resume <apply_id>]] [--no-gen] [--skip-functions] [--yes] \u2014 apply the config; --allow-destructive also deletes deployed functions and stored AI templates the config no longer declares; --strict exits 1 when anything declared was refused, deferred or left unwired; --server prints the trigger wiring the server reconciled (--json: one result document on stdout)",
|
|
16654
16740
|
gen: "usage: vxil gen [--check] [--offline] [--out <f>] [--no-mcp] [--mcp-out <f>] \u2014 generate vxil.types.ts (+ vxil.mcp.json); --offline keeps an existing API_VERSIONS block; --check compares instead of writing",
|
|
16655
16741
|
types: "usage: vxil types \u2014 alias of `vxil gen` (run `vxil gen --help`)",
|
|
@@ -16768,6 +16854,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
|
16768
16854
|
"raw",
|
|
16769
16855
|
"strict",
|
|
16770
16856
|
"strict-api-state",
|
|
16857
|
+
"strict-runtime",
|
|
16771
16858
|
"strict-secrets",
|
|
16772
16859
|
"sweep",
|
|
16773
16860
|
"watch",
|
|
@@ -16967,8 +17054,8 @@ function ledgerGrantPeriodWarnings(cfg) {
|
|
|
16967
17054
|
});
|
|
16968
17055
|
}
|
|
16969
17056
|
for (const [priceRef, tier] of Object.entries(ledger.priceMap ?? {})) {
|
|
16970
|
-
if (YEARLY_ID.test(priceRef) && periodsOf(tier).includes("monthly")) {
|
|
16971
|
-
out.push(`/ledger/priceMap/${priceRef}: looks like a yearly price, but tier '${tier}' has a 'monthly' grant \u2014 tier grants mint once per billing period (keyed by its start), so it mints once a year. For monthly credits on a yearly plan,
|
|
17057
|
+
if (YEARLY_ID.test(priceRef) && periodsOf(tier).includes("monthly") && ledger.subPeriodGrants !== true) {
|
|
17058
|
+
out.push(`/ledger/priceMap/${priceRef}: looks like a yearly price, but tier '${tier}' has a 'monthly' grant \u2014 tier grants mint once per billing period (keyed by its start), so it mints once a year. For monthly credits on a yearly plan, set ledger.subPeriodGrants: true`);
|
|
16972
17059
|
}
|
|
16973
17060
|
}
|
|
16974
17061
|
for (const [productId, rule] of Object.entries(ledger.productMap ?? {})) {
|
|
@@ -18264,6 +18351,7 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
18264
18351
|
return result;
|
|
18265
18352
|
}
|
|
18266
18353
|
const reasons = /* @__PURE__ */ new Map();
|
|
18354
|
+
let would = deps.dryRun ? {} : null;
|
|
18267
18355
|
let withPassword = 0;
|
|
18268
18356
|
let withLinks = 0;
|
|
18269
18357
|
let linked = 0;
|
|
@@ -18284,13 +18372,19 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
18284
18372
|
entries.push(entry);
|
|
18285
18373
|
digests.push(h);
|
|
18286
18374
|
}
|
|
18287
|
-
if (deps.dryRun) continue;
|
|
18375
|
+
if (deps.dryRun && would === null) continue;
|
|
18288
18376
|
for (let i = 0; i < entries.length; i += CREDENTIALS_BATCH_MAX) {
|
|
18289
18377
|
const chunk = entries.slice(i, i + CREDENTIALS_BATCH_MAX);
|
|
18290
18378
|
const res = await deps.api("POST", "/v1/auth/users/import", {
|
|
18291
18379
|
users: chunk,
|
|
18292
|
-
...opts.oidcIssuer ? { oidc_issuer: opts.oidcIssuer } : {}
|
|
18380
|
+
...opts.oidcIssuer ? { oidc_issuer: opts.oidcIssuer } : {},
|
|
18381
|
+
...deps.dryRun ? { dry_run: true } : {}
|
|
18293
18382
|
});
|
|
18383
|
+
if (deps.dryRun && (res.status !== 200 || res.body.error)) {
|
|
18384
|
+
deps.log(` \u26A0 ${apiErrorLine("credentials: server preview unavailable (POST /v1/auth/users/import dry_run)", res)} \u2014 the local counts below still stand; --execute needs auth enabled and a SERVER key holding auth:write`);
|
|
18385
|
+
would = null;
|
|
18386
|
+
break;
|
|
18387
|
+
}
|
|
18294
18388
|
if (res.status !== 200 || res.body.error) {
|
|
18295
18389
|
const hint = res.body.error?.code === "capability_not_enabled" ? " \u2014 enable auth (with emailPassword) on this project first" : res.body.error?.code === "server_only" || res.body.error?.code === "insufficient_scope" ? " \u2014 run the migration with a SERVER key holding auth:write" : "";
|
|
18296
18390
|
result.errors.push(apiErrorLine(`credentials: POST /v1/auth/users/import (${chunk.length} users) failed`, res) + hint + " \u2014 the cursor was NOT advanced; fix and re-run (the import is idempotent)");
|
|
@@ -18301,10 +18395,17 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
18301
18395
|
linked += typeof data2.identities_linked === "number" ? data2.identities_linked : 0;
|
|
18302
18396
|
const taken = (data2.results ?? []).filter((r) => r.status === "skipped" && r.reason === "id_taken").length;
|
|
18303
18397
|
if (taken > 0) {
|
|
18304
|
-
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.`);
|
|
18398
|
+
result.errors.push(`credentials: ${deps.dryRun ? "--execute would fail \u2014 " : ""}${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.`);
|
|
18305
18399
|
result.cursor = cursors[KEY] ?? null;
|
|
18306
18400
|
return result;
|
|
18307
18401
|
}
|
|
18402
|
+
if (would !== null) {
|
|
18403
|
+
for (const r of data2.results ?? []) {
|
|
18404
|
+
would[r.status] = (would[r.status] ?? 0) + 1;
|
|
18405
|
+
if (r.status === "skipped") reasons.set(r.reason ?? "unknown", (reasons.get(r.reason ?? "unknown") ?? 0) + 1);
|
|
18406
|
+
}
|
|
18407
|
+
continue;
|
|
18408
|
+
}
|
|
18308
18409
|
for (const r of data2.results ?? []) {
|
|
18309
18410
|
if (r.status === "skipped") {
|
|
18310
18411
|
result.skipped++;
|
|
@@ -18315,12 +18416,21 @@ async function loadCredentials2({ deps, adapter, state, save, opts, delta = fals
|
|
|
18315
18416
|
}
|
|
18316
18417
|
for (let j = 0; j < chunk.length; j++) sent[chunk[j].id] = digests[i + j];
|
|
18317
18418
|
}
|
|
18419
|
+
if (deps.dryRun) continue;
|
|
18318
18420
|
cursors[KEY] = batch.nextCursor;
|
|
18319
18421
|
save();
|
|
18320
18422
|
if (batch.nextCursor === null) break;
|
|
18321
18423
|
}
|
|
18322
18424
|
if (deps.dryRun) {
|
|
18323
18425
|
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");
|
|
18426
|
+
if (would !== null) {
|
|
18427
|
+
const skippedN = would["skipped"] ?? 0;
|
|
18428
|
+
deps.log(`credentials: server preview: created=${would["created"] ?? 0} updated=${would["updated"] ?? 0} kept=${would["kept"] ?? 0} skipped=${skippedN}` + (reasons.size > 0 ? ` (${[...reasons].map(([r, n]) => `${r}=${n}`).join(" ")})` : "") + (opts.withIdentities ? `, ${linked} social link(s) would be created` : "") + " \u2014 the server ran each batch of up to 500 and rolled it back: exact within a batch, not across batches (an address or social subject repeated in a LATER batch can still be email_taken / not linked on --execute)");
|
|
18429
|
+
if (reasons.has("not_registered")) {
|
|
18430
|
+
deps.log(" `not_registered` in a preview: the users step has not registered them yet (it writes nothing in a dry run either) \u2014 run `vxil migrate data --execute --only users`, then preview again for their real outcome");
|
|
18431
|
+
}
|
|
18432
|
+
}
|
|
18433
|
+
reasons.clear();
|
|
18324
18434
|
} else {
|
|
18325
18435
|
cursors[KEY] = null;
|
|
18326
18436
|
save();
|
|
@@ -19343,10 +19453,12 @@ function saveMigrateState(stateFile, state) {
|
|
|
19343
19453
|
mkdirSync2(dirname3(stateFile), { recursive: true });
|
|
19344
19454
|
writeFileSync3(stateFile, JSON.stringify(state, null, 2) + "\n");
|
|
19345
19455
|
}
|
|
19456
|
+
var SERVER_PREVIEW_POSTS = /* @__PURE__ */ new Set(["/v1/auth/users/import"]);
|
|
19346
19457
|
function guardReadOnly(api) {
|
|
19347
19458
|
return async (method, path, body, headers) => {
|
|
19348
19459
|
const m = method.toUpperCase();
|
|
19349
|
-
|
|
19460
|
+
const preview = m === "POST" && SERVER_PREVIEW_POSTS.has(path) && typeof body === "object" && body !== null && body.dry_run === true;
|
|
19461
|
+
if (m !== "GET" && m !== "HEAD" && !preview) {
|
|
19350
19462
|
throw new Error(
|
|
19351
19463
|
`dry-run: blocked ${m} ${path} \u2014 a loader attempted a write without --execute (loader bug; nothing was sent). Re-run with --execute to write.`
|
|
19352
19464
|
);
|
|
@@ -21064,11 +21176,11 @@ export default defineConfig({
|
|
|
21064
21176
|
"hasFunctions": true,
|
|
21065
21177
|
"byoKeys": [],
|
|
21066
21178
|
"configSrc": "import { defineConfig } from '@vxil/config';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// \"Push notifications\" \u2014 DEVICE TOKENS DONE PROPERLY.\n//\n// Sending a push is the easy part: one HTTPS POST to your push service. What\n// actually breaks in production is the REGISTRY \u2014 the mapping from a user to\n// the devices they are currently holding. Tokens rotate, phones get sold, users\n// sign out and a colleague signs in on the same handset, and a token that once\n// belonged to Alice starts delivering Alice's notifications to Bob. That is a\n// privacy incident, not a bug.\n//\n// So this blueprint is a registry with three invariants and a small amount of\n// sending bolted on \u2014 and it is THE supported Expo path on vxil (guide 17):\n// 1. ONE TOKEN, ONE USER \u2014 `token` is `unique`, enforced by the platform.\n// 2. RE-OWN ON CHANGE \u2014 a token that reappears under a different user is\n// REASSIGNED, never duplicated. From your backend (server mode) that is\n// immediate; from the app itself (end-user mode, which cannot see another\n// user's row) it is ASYNC: a claim row + a server-mode cmsHook.\n// 3. PRUNE STALE \u2014 the push service tells you when a token is dead; a cron\n// collects those receipts and deletes the rows, so your registry shrinks.\n// It walks the WHOLE registry over successive ticks (a persisted cursor).\n// Plus the two things every consumer app ends up writing:\n// \u2022 ERASURE \u2014 a user erased under GDPR (auth.user.erased / user.erased) or\n// deleted (user.deleted) loses every device row, claim and preferences\n// row \u2014 never on session.revoked, which fires on every refresh.\n// \u2022 A REMINDER LADDER \u2014 an hourly cron nudging inactive users (1 d, 3 d, 7 d),\n// honouring each user's opt-out and quiet hours in THEIR time zone.\n//\n// \u2022 cms \u2192 `push_tokens` (owner-scoped, `token` unique), `push_token_claims`\n// (an app's async re-own request), `push_prefs` (reminders,\n// time zone, quiet hours, last activity), `push_state` (cursors)\n// \u2022 functions \u2192 `register-token` (the registry: http + the claim cmsHook + the\n// erasure webhooks), `send-push` (http send + the hourly ladder\n// cron), `prune-receipts` (the receipts cron)\n//\n// Three functions and two crons (the fastest hourly) on purpose: that is inside\n// the Free plan's function limits for staging and development projects (guide\n// 12 has the per-plan table), so the whole kit runs on a free project while\n// you build the app. Every tick is bounded and resumable, so a tick the plan\n// sheds is harmless: the next one picks up where the last stopped.\n//\n// Written against Expo's push service because it fronts BOTH APNs and FCM with\n// one token format. Swapping in raw FCM or APNs is a change to two constants\n// and the message body \u2014 see README.md.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nexport default defineConfig({\n env: 'staging',\n\n features: {\n cms: {\n // Fail-safe: a verified end-user may only touch collections that declare\n // an ownerField. `push_tokens` does, so a signed-in device can read and\n // refresh its OWN rows and structurally cannot enumerate anyone else's.\n strictEndUserScope: true,\n hooks: {\n // A row with no token is a row that can never receive anything.\n token_present: {\n collection: 'push_tokens',\n event: 'beforeWrite',\n kind: 'validate',\n expr: 'len(item.token) > 0',\n message: 'a push token is required',\n },\n // The ladder walk filters on `ladder_step < 3` (a slot compare, which\n // never matches an absent value), so every prefs row carries one: a\n // write that leaves it out gets 0.\n ladder_step_default: {\n collection: 'push_prefs',\n event: 'beforeWrite',\n kind: 'derive',\n field: 'ladder_step',\n expr: 'coalesce(item.ladder_step, 0)',\n },\n },\n },\n functions: { enabled: true },\n },\n\n cms: {\n collections: {\n push_tokens: {\n singular: 'push_token',\n // Owner-scoped: an end-user session sees only its own devices.\n ownerField: 'end_user',\n fields: {\n // \u2605 INVARIANT 1. `unique` is the TOP-LEVEL field attribute (there is no\n // `validation.unique`). vxil enforces it with its uniqueness check, so a\n // second registration of the same token is a 409 `unique_violation` \u2014\n // atomically, under concurrency, without a read-then-write race.\n // NOTE: a unique value is capped at 256 characters. Expo tokens\n // (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside that;\n // if you switch to raw FCM registration tokens, store a hash here and\n // keep the full token in an unindexed `text` field.\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' }, // the owner\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n // The device's language, captured at registration \u2014 this is what makes\n // a localized push possible without a second lookup per device.\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'fr', \u2026\n // The receipt id returned by the LAST send. `prune-receipts` trades it\n // for a verdict and deletes the row when the service says the device\n // is gone.\n receipt_id: { type: 'string' },\n // When that receipt id was parked. The push service keeps a receipt\n // for a limited time; past RECEIPT_MAX_AGE the id is cleared as\n // unknowable instead of being asked about forever.\n receipt_at: { type: 'datetime' },\n // Consecutive delivery failures \u2014 a soft signal for your own dashboards\n // (the authoritative kill signal is the receipt, not this counter).\n failures: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_seen: { type: 'datetime', indexSlot: 't1' }, // last registration/refresh\n checked_at: { type: 'datetime', indexSlot: 't2' }, // last receipt check\n last_error: { type: 'text' },\n },\n },\n\n // \u2605 INVARIANT 2, the app's half. An end-user session cannot see \u2014 let\n // alone re-own \u2014 a row that belongs to someone else, so when the app\n // registers a token another user holds, `register-token` files a CLAIM\n // here (owned by the caller) and answers 202. The `register-token`\n // cmsHook binding then runs in SERVER mode, moves the token, and deletes\n // the claim. Usually done within seconds.\n push_token_claims: {\n singular: 'push_token_claim',\n ownerField: 'end_user',\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1' },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3', validation: { enum: ['ios', 'android', 'web'] } },\n locale: { type: 'string', indexSlot: 's4' },\n },\n },\n\n // The reminder ladder's per-user state. Owner-scoped: the app reads and\n // writes its OWN row (opt out, set quiet hours, stamp `last_active` and\n // reset `ladder_step` to 0 whenever the app comes to the foreground).\n push_prefs: {\n singular: 'push_pref',\n ownerField: 'end_user',\n fields: {\n end_user: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // IANA zone ('Asia/Riyadh'); quiet hours are evaluated in it. An\n // unknown zone is treated as UTC.\n timezone: { type: 'string', indexSlot: 's2' },\n // false = no reminders at all (absent = reminders on).\n reminders: { type: 'bool' },\n // Local hours [quiet_start, quiet_end) during which nothing is sent;\n // the window may wrap midnight (22 \u2192 8). Equal values = no quiet hours.\n quiet_start: { type: 'int', validation: { min: 0, max: 23 } },\n quiet_end: { type: 'int', validation: { min: 0, max: 23 } },\n last_active: { type: 'datetime', indexSlot: 't1' },\n // The next rung (0..3) since `last_active` (the app resets it to 0; the\n // `ladder_step_default` derive writes 0 when a write leaves it out).\n ladder_step: { type: 'int', indexSlot: 'n1', validation: { min: 0 } },\n last_reminded_at: { type: 'datetime', indexSlot: 't2' },\n },\n },\n\n // Where each cron left off (`key` = the job). No ownerField, so with\n // `strictEndUserScope` an end-user session cannot touch it at all.\n push_state: {\n singular: 'push_state',\n fields: {\n key: { type: 'string', required: true, indexSlot: 's1', unique: true },\n // the last item id walked; '' = start again from the newest row\n cursor: { type: 'string' },\n updated_at: { type: 'datetime', indexSlot: 't1' },\n },\n },\n },\n },\n\n functions: {\n // THE REGISTRY \u2014 one bundle, five front doors (a function is code + a SET\n // of trigger bindings):\n // \u2022 http POST /v1/fn/register-token { token, user_id?, platform?, locale? }\n // server mode (your backend) re-owns at once; end-user mode\n // (the app) files a claim for a token someone else holds \u2192 202.\n // \u2022 cmsHook a new `push_token_claims` row \u2192 re-own it in SERVER mode,\n // delete the claim. `retry` re-delivers a failed attempt.\n // \u2022 webhook `auth.user.erased` (the auth erase), `user.erased` (the\n // admin erase) and `user.deleted` (the admin delete) \u2192 delete\n // every device row, claim and preferences row of that user.\n // NOT `auth.session.revoked`: that fires on every refresh-token\n // rotation, and would sign devices out of push at random.\n 'register-token': {\n entry: './functions/register-token.ts',\n trigger: { kind: 'http' },\n triggers: [\n { kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'auth.user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.erased', retry: { maxAttempts: 3 } },\n { kind: 'webhook', source: 'user.deleted', retry: { maxAttempts: 3 } },\n ],\n scopes: ['cms:read', 'cms:write'],\n egressAllow: [], // pure vxil-internal\n },\n\n // THE SENDER \u2014 two front doors:\n // \u2022 http POST /v1/fn/send-push { user_ids, messages, data? } \u2014 resolves\n // each user's live devices, picks the message for each device's\n // locale, and posts to the push service in batches.\n // \u2022 cron hourly: THE REMINDER LADDER. Users inactive 1 d / 3 d / 7 d get\n // at most one nudge per step (a user found already a week idle gets\n // only the 7-day one), never during their quiet hours, never after\n // opting out. Each step is claimed (If-Match) BEFORE the send, so\n // an overlapping tick can never send it twice.\n 'send-push': {\n entry: './functions/send-push.ts',\n trigger: { kind: 'http' },\n triggers: [{ kind: 'cron', schedule: '0 * * * *' }],\n scopes: ['cms:read', 'cms:write'],\n // Deny-by-default egress. `exp.host` is Expo's push host; `api.expo.dev`\n // is its newer alias. For raw FCM use 'fcm.googleapis.com'; for APNs,\n // 'api.push.apple.com' (and see README.md on the token-auth swap).\n egressAllow: ['exp.host', 'api.expo.dev'],\n // NO `secrets` here on purpose. Expo's push API needs no credential in its\n // default configuration, and secret resolution is FAIL-CLOSED: a declared\n // `secret:<name>` ref with no stored value makes every invocation\n // `409 secret_missing` before your code runs. Declare a secret only once\n // you will actually set it \u2014 see README.md \xA7\"If your push service needs a key\".\n },\n\n // \u2605 INVARIANT 3. THE GARBAGE COLLECTOR. A push service accepts a send and\n // only later tells you the device was gone \u2014 that verdict arrives as a\n // RECEIPT, not as the send response. Every 6 hours this cron trades parked\n // receipt ids for verdicts and deletes the rows the service declared dead.\n // It resumes where the last tick stopped (`push_state`), so a registry of\n // any size is walked end to end.\n 'prune-receipts': {\n entry: './functions/prune-receipts.ts',\n trigger: { kind: 'cron', schedule: '30 */6 * * *' },\n scopes: ['cms:read', 'cms:write'],\n egressAllow: ['exp.host', 'api.expo.dev'],\n },\n },\n});\n",
|
|
21067
|
-
"readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\nguide 17 \u2014 vxil for Expo apps (vxil.com/docs/guide/17-mobile-apps-with-expo).\n\n```bash\nmkdir my-push && cd my-push\nvxil init --template push-notifications\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # four collections + three functions (two of them crons); writes vxil.types.ts\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, enforced by vxil's uniqueness check. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\nCalled from the app, the app's thin-client key needs `functions:invoke` (it is allowed on a public key).\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push`\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\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\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Declare the outbound subscription under `features` in `vxil.config.ts`, and `vxil push`\nconverges it by `target_url` (declared API state \u2014 the funnel-saas blueprint has the whole table):\n\n```ts\nwebhooks: {\n enabled: true,\n subscriptions: [\n { target_url: 'https://your-app.example.com/hooks/vxil', event_prefixes: ['payments.entitlement.'] },\n ],\n},\n```\n\nor create it at runtime as a row you manage yourself.\n\nEither way the key needs `webhooks:read` and `webhooks:write`. The project key quickstart minted for\nthis blueprint (cms, functions) has neither, so `vxil push` reports `webhooks: 403 insufficient_scope`\nand the curl is refused. Start from `vxil quickstart --features cms,functions,webhooks`, or (after\n`vxil login`) mint one with `vxil keys mint --name hooks --scopes webhooks:read,webhooks:write --json`\nand use it as `$HOOKS_KEY`:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $HOOKS_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in the **alerts-to-slack** blueprint reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** the **alerts-to-slack** blueprint for the audit-stream drain used in\nexample (b).\n",
|
|
21179
|
+
"readme": "# Push notifications (mobile)\n\nSending a push is the easy part: one HTTPS POST to your push service. What breaks in production is the\n**registry** \u2014 the mapping from a user to the devices they are currently holding.\n\nTokens rotate. Phones get sold. A user signs out and a colleague signs in on the same handset. Unless\nsomething reassigns that token, the previous owner's notifications keep arriving on someone else's lock\nscreen \u2014 a privacy incident, not a bug. And nobody collects delivery **receipts**, so the registry only\never grows: dead tokens are re-sent to forever and delivery rates look worse than they are.\n\nThis blueprint is that registry, with three invariants, plus the sending, the erasure clean-up and the\nre-engagement ladder every consumer app ends up writing. It is **the supported Expo path on vxil** \u2014\nvxil holds no push credential and runs no push service; your functions call Expo (which fronts APNs and\nFCM) through the egress allowlist. The whole mobile picture is in\nguide 17 \u2014 vxil for Expo apps (vxil.com/docs/guide/17-mobile-apps-with-expo).\n\n```bash\nmkdir my-push && cd my-push\nvxil init --template push-notifications\nvxil quickstart --env staging --no-push --invite <code> # --invite only for a new e-mail\n # (or `vxil link <slug> --env staging` for an existing backend)\nvxil projects workload <slug> staging # Free: functions deploy to staging/development projects\nvxil push # four collections + three functions (two of them crons); writes vxil.types.ts\n```\n\nThree functions and two crons (the fastest hourly) on purpose: that is inside the Free plan's function\nlimits for staging and development projects (the per-plan table is in guide 12), so a free project runs\nall of it while you build the app. Every cron tick is bounded and resumable, so a tick the plan sheds is\nharmless \u2014 the next one picks up where the last stopped.\n\n> **Plan note.** The three functions deploy on the Free plan when the project's workload is `staging` or\n> `development` (the `vxil projects workload <slug> staging` step above; create the project with quickstart,\n> not `vxil projects create`, whose empty project cannot take this config's first push today). On a Free `production` project, `vxil push`\n> stops before it writes anything, naming the plan and the ways out: change the workload or upgrade\n> to Developer, or run `vxil push --skip-functions` to apply the collections and config without the\n> functions.\n\n## The three invariants\n\n| # | Invariant | Where it is enforced |\n|---|---|---|\n| **1** | **One token, one user** | `token: { unique: true }` \u2014 the *top-level* field attribute, enforced by vxil's uniqueness check. A second registration of the same token is a `409 unique_violation`, atomically, with no read-then-write race. |\n| **2** | **Re-own on change** | `functions/register-token.ts` \u2014 on that 409 it looks the row up and **PATCHes the owner** with `If-Match`. Reassign, never duplicate. From the app itself (end-user mode) the move is **async**: a claim row + a server-mode `cmsHook` (below). |\n| **3** | **Prune stale** | `functions/prune-receipts.ts` \u2014 a cron every 6 hours trades parked receipt ids for verdicts and **deletes** every row the service reports as `DeviceNotRegistered`. It resumes where the last tick stopped, so the **whole** registry is walked, whatever its size. |\n\nThere is no `validation: { unique: true }` \u2014 that key does not exist and would be silently ignored.\nUniqueness is the top-level `unique` attribute, on scalar field types only.\n\n## The registry\n\n```ts\npush_tokens: {\n ownerField: 'end_user', // a signed-in device sees only its own rows\n fields: {\n token: { type: 'string', required: true, indexSlot: 's1', unique: true },\n end_user: { type: 'string', required: true, indexSlot: 's2' },\n platform: { type: 'string', indexSlot: 's3' }, // ios | android | web\n locale: { type: 'string', indexSlot: 's4' }, // 'en', 'ar', 'pt-BR', \u2026\n receipt_id: { type: 'string' }, // the last send's pending verdict\n receipt_at: { type: 'datetime' }, // when it was parked (48 h \u2192 cleared)\n failures: { type: 'int', indexSlot: 'n1' },\n last_seen: { type: 'datetime', indexSlot: 't1' },\n checked_at: { type: 'datetime', indexSlot: 't2' },\n last_error: { type: 'text' },\n },\n}\n```\n\nBeside it: `push_token_claims` (the app's async re-own requests, owner-scoped), `push_prefs` (one row per\nuser for the reminder ladder, owner-scoped, `end_user` unique) and `push_state` (each cron's cursor \u2014 no\n`ownerField`, so no end-user session can touch it).\n\n`strictEndUserScope: true` is on, so a verified end-user session may only touch collections that declare\nan `ownerField`. `push_tokens` does \u2014 a device can refresh **its own** rows and structurally cannot\nenumerate anyone else's.\n\n> **The 256-character cap.** A unique value is capped at 256 characters. Expo tokens\n> (`ExponentPushToken[\u2026]`) and APNs device tokens are far inside it. If you switch to raw **FCM\n> registration tokens**, store a hash in `token` and keep the full value in an unindexed `text` field \u2014\n> otherwise long tokens are rejected at write time.\n\n## Registering \u2014 from your backend or from the app\n\n`POST /v1/fn/register-token { token, user_id?, platform?, locale? }`\n\nInvariant 2 needs to reassign a token **away from another user**, which needs a tenant-wide read. A\nfunction invoked with a verified end-user session gets **owner-scoped** callback tokens \u2014 the platform\nstamps the principal onto every scoped token the function mints downstream \u2014 so in end-user mode the\nother user's row is simply invisible. That is the fail-safe working, not a gap. So there are two paths:\n\n| Called from | `user_id` | A token another user holds | Response |\n|---|---|---|---|\n| **your backend** (server key) | from the body | re-owned at once (`If-Match`) | `200 {action:'reowned', previous_owner}` |\n| **the app** (end-user session) | the **verified** session id \u2014 a body `user_id` is ignored | a **claim** row in `push_token_claims`, owned by the caller | `202 {action:'reown_queued', claim_id}` |\n\nEither way a new token is `201 {action:'created'}` and an unchanged owner is `200 {action:'refreshed'}`.\nCalled from the app, the app's thin-client key needs `functions:invoke` (it is allowed on a public key).\n\n**The async re-own.** `register-token` declares a second binding,\n`{ kind: 'cmsHook', collection: 'push_token_claims', event: 'beforeCreate', retry: { maxAttempts: 3 } }`.\nA platform-delivered hook carries **no** end-user, so it runs in server mode: it re-reads the claim,\nre-owns the token row for the claimant (or creates it, if the old holder deleted it meanwhile), and\ndeletes the claim. It usually lands within seconds; a failed attempt answers non-2xx and the `retry`\nre-delivers it, and every step is idempotent (a claim that is already gone is done).\n\n**What a claim can and cannot do.** A claim can only name its caller as the new owner (the platform\nstamps the verified owner on the row), and a push token is a bearer credential only the device itself is\nhanded by the OS. Claiming a token you do not hold moves **your** notifications to that device \u2014 no data\nreaches you. The trade-off to know about is availability: a signed-in user who somehow learned another\nuser's Expo token could move that device away from its owner, who then stops receiving pushes (security\nalerts included) until their app starts again and re-registers it. Tokens are not shown to other users\nby anything in this kit, so that needs the token to have leaked. If your app treats pushes as critical,\nadd a `validate` hook on `push_token_claims` that caps open claims, or have the app re-register on every\nforeground (it is idempotent \u2014 `200 {action:'refreshed'}` costs one write).\n\n**Sign-out.** A device that signs out should stop receiving the old user's pushes before anyone else\nsigns in: delete the device's own row from the app (`DELETE /v1/cms/items/push_tokens/{item_id}` with\nthe session \u2014 an owner may delete its own rows), or just let the next sign-in re-own it.\n\n## Erasure and deletion \u2014 `auth.user.erased`, not `session.revoked`\n\n`register-token` also binds `{ kind: 'webhook', source: 'auth.user.erased' }`,\n`{ kind: 'webhook', source: 'user.erased' }` (the admin `DELETE /v1/users/{id}?erase=true`) and\n`{ kind: 'webhook', source: 'user.deleted' }` (the admin delete without erase). That user loses every\n`push_tokens` row, pending claim and their `push_prefs` row (time zone, quiet hours, activity) \u2014 device\ntokens and preferences are personal data, vxil's erasure scrubs its own tables, not your collections,\nand a deleted account must stop receiving reminder nudges.\n\nA **merge or rekey** (`auth.user.merged` / `auth.user.rekeyed` \u2014 a guest becoming a registered user)\nneeds no binding: the app's next start registers its token under the new id, which re-owns the device.\nThe old id's prefs row goes quiet on its own (it has no devices left to nudge).\n\nDo **not** clean up on `auth.session.revoked`: it fires on every refresh-token rotation as well as on\nsign-out, so a cleanup bound to it would unregister devices at random.\n\n## Sending \u2014 localized per device, not per user\n\n`POST /v1/fn/send-push` \u2014 **server keys and the cron only**: a call made with an end user's session answers `403`. Deploying the kit without `send-push` (sending from your own server instead) is supported; the re-own lives in `register-token`.\n\n```json\n{\n \"user_ids\": [\"usr_a\", \"usr_b\"],\n \"messages\": {\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\" }\n}\n```\n\nOne person can carry an English phone and an Arabic tablet. Because `locale` lives on the **token row**,\neach device gets the right copy with no extra lookup \u2014 exact match (`pt-BR`), then the base language\n(`pt`), then `default`.\n\nThe response distinguishes the two failure kinds, because they call for opposite actions:\n\n- an **immediate** `DeviceNotRegistered` \u2192 the token is dead now; the row is deleted;\n- anything else (rate limits, a message too big, a transient fault) \u2192 counted in `failures`, device kept.\n Deleting a device because one message was malformed is how a registry loses real users.\n- an **accepted** message returns a *receipt id*, not a delivery. That id is parked on the row, and the\n real verdict is collected by the cron below.\n\n## The receipts cron \u2014 the loop almost nobody closes\n\n`prune-receipts` runs at `30 */6 * * *`. It resumes the walk of `push_tokens` where the previous tick\nstopped (its cursor lives in `push_state`), collects the rows carrying a `receipt_id`, asks the push\nservice for those receipts in batches, and acts \u2014 `ok` clears the id, `DeviceNotRegistered` deletes the\nrow, anything else counts a failure and keeps the device. A receipt that is not ready yet is simply\nabsent from the response, which is not an error; it is asked again on the next walk. A receipt id parked\nfor more than 48 hours with no answer is cleared \u2014 the service no longer keeps it, and asking forever\nwould pin the row.\n\nEverything is bounded: one tick reads at most 2,000 rows and acts on at most 300, so its outbound calls\nstay in the low hundreds. Because the cursor persists, a registry of **any** size is walked end to end\nover successive ticks; when the walk reaches the oldest row the cursor resets and the next tick starts\nagain from the newest (rows registered during a walk are covered by the next one).\n\n> **A paging detail worth stealing.** The cron pages with `?cursor=` and **no** `?sort=`. In the\n> default order (newest first) the cursor is a plain item id, so it stays valid across ticks \u2014 persist it\n> and the walk resumes exactly where it stopped.\n\n**Never clears a newer receipt.** Each clear is a `PATCH` with `If-Match` on the row version the walk\nread. If `send-push` parked a newer receipt id on the same device in between, the clear is refused and\ncounted as `raced`; the newer id is checked on the next walk.\n\n## The reminder ladder \u2014 re-engagement that respects the user\n\n`send-push` has a second binding, `{ kind: 'cron', schedule: '0 * * * *' }`. Every hour it nudges users\nwho stopped opening the app: once after **1 day**, once after **3**, once after **7** \u2014 then silence until\nthey come back. The copy lives in the `LADDER` constant at the top of `functions/send-push.ts` (per\nlocale, picked per device like any send).\n\nIt reads `push_prefs`, a row per user **the app writes itself** (owner-scoped):\n\n| Field | Meaning |\n|---|---|\n| `reminders` | `false` = never. Absent = on. |\n| `timezone` | IANA zone (`Asia/Riyadh`); quiet hours are evaluated in it. Unknown = UTC. |\n| `quiet_start`, `quiet_end` | local hours `[start, end)` with nothing sent; may wrap midnight (`22` \u2192 `8`). Equal = none. |\n| `last_active` | stamp it whenever the app comes to the foreground\u2026 |\n| `ladder_step` | \u2026and reset this to `0` at the same time. The cron advances it. Left out on a write, a `derive` hook stores `0`. |\n\n```ts\n// in the app, on AppState 'active' (end-user session)\nawait vx.from('push_prefs').patch(prefsId, { last_active: new Date().toISOString(), ladder_step: 0 });\n```\n\nThree guarantees:\n\n- **Only idle users are read.** The walk filters on `last_active` and `ladder_step < 3` (both indexed),\n so active users and users who finished the ladder cost nothing; it resumes where the last tick stopped\n (`push_state`), reading at most 2,000 rows and nudging at most 100 users per tick. Users who opted out\n are still read (an absent `reminders` means on, which a filter cannot express) and skipped.\n- **Never twice, never a burst.** Each step is claimed on the prefs row with `If-Match` **before** the\n send \u2014 two overlapping ticks cannot both send it. A send that fails after the claim is a missed nudge,\n never a duplicate one. When several rungs are due at once (the kit added to a live app, reminders\n turned back on, a cron that was held), only the **highest** is sent and the ones below are skipped; and\n a rung waits its own gap after the previous nudge (`last_reminded_at`), so the 3-day and 7-day nudges\n stay two and four days after the one before.\n- **Never at night.** A user inside their quiet hours is skipped and picked up by a later tick once the\n window has passed (the next one, unless the walk spans more than one tick).\n\n## Example: post-purchase push on `payments.entitlement.changed`\n\nThe payments feature writes `payments.entitlement.changed` to your audit stream on every entitlement\ntransition, carrying `end_user_id`, the previous and new tier, `status`, `until`, `reason` and\n`environment`. Turning that into \"your Pro plan is live, in the language of the device\" is a wiring\nexercise, not new code \u2014 there are two shapes, both using the same `send-push` call:\n\n**(a) Subscribe and call in.** Declare the outbound subscription under `features` in `vxil.config.ts`, and `vxil push`\nconverges it by `target_url` (declared API state \u2014 the funnel-saas blueprint has the whole table):\n\n```ts\nwebhooks: {\n enabled: true,\n subscriptions: [\n { target_url: 'https://your-app.example.com/hooks/vxil', event_prefixes: ['payments.entitlement.'] },\n ],\n},\n```\n\nor create it at runtime as a row you manage yourself.\n\nEither way the key needs `webhooks:read` and `webhooks:write`. The project key quickstart minted for\nthis blueprint (cms, functions) has neither, so `vxil push` reports `webhooks: 403 insufficient_scope`\nand the curl is refused. Start from `vxil quickstart --features cms,functions,webhooks`, or (after\n`vxil login`) mint one with `vxil keys mint --name hooks --scopes webhooks:read,webhooks:write --json`\nand use it as `$HOOKS_KEY`:\n\n```bash\ncurl -X POST https://api.vxil.com/v1/webhooks/subscriptions \\\n -H \"authorization: Bearer $HOOKS_KEY\" -H 'content-type: application/json' \\\n -d '{\"target_url\":\"https://your-app.example.com/hooks/vxil\",\"event_prefixes\":[\"payments.entitlement.\"]}'\n```\n\nYour endpoint verifies the signed delivery, then calls `send-push` with a key holding\n`functions:invoke`:\n\n```ts\n// inside your webhook receiver \u2014 event.payload.end_user_id / .tier are the fields above\nawait fetch('https://api.vxil.com/v1/fn/send-push', {\n method: 'POST',\n headers: { authorization: `Bearer ${process.env.VXIL_API_KEY}`, 'content-type': 'application/json' },\n body: JSON.stringify({\n user_ids: [event.payload.end_user_id],\n messages: {\n default: { title: 'You\\'re in', body: `${event.payload.tier} is active on this device.` },\n ar: { title: '\u062A\u0645 \u0627\u0644\u062A\u0641\u0639\u064A\u0644', body: `\u062A\u0645 \u062A\u0641\u0639\u064A\u0644 ${event.payload.tier} \u0639\u0644\u0649 \u0647\u0630\u0627 \u0627\u0644\u062C\u0647\u0627\u0632.` },\n },\n data: { screen: 'billing', tier: event.payload.tier },\n }),\n});\n```\n\n**(b) Drain the audit stream on a cron.** If you would rather keep everything inside your backend, the\nwatermark-plus-allow-list pattern in the **alerts-to-slack** blueprint reads the same\nevents with `GET /v1/audit/export?after_id=\u2026` and needs no public endpoint at all. Same events, no\ninbound surface.\n\nWhichever you pick, send on the **`from_tier \u2192 tier` crossing**, not on every event: entitlement snapshots\nare refolded on redelivery, and \"Pro is active\" three times is how an app gets its notifications muted.\n\n## Swapping the push service\n\nThe blueprint targets Expo because it fronts **both** APNs and FCM with one token format. To swap:\n\n| Service | `PUSH_URL` | `egressAllow` | Also change |\n|---|---|---|---|\n| Expo (default) | `https://exp.host/--/api/v2/push/send` | `exp.host`, `api.expo.dev` | \u2014 |\n| FCM (HTTP v1) | the HTTP v1 send method under `https://fcm.googleapis.com/v1/projects/<id>/` \u2014 the exact URL is in `functions/send-push.ts` | `fcm.googleapis.com` | one message per request; an OAuth bearer; the `message.notification` body shape; hash long tokens (see the 256-char cap above) |\n| APNs | `https://api.push.apple.com/3/device/<token>` | `api.push.apple.com` | one request per device; a JWT `authorization`; an `apns-topic` header; the `aps` payload shape |\n\n### If your push service needs a key\n\nAdd the ref to the function and set the value:\n\n```ts\n'send-push': { /* \u2026 */ secrets: ['secret:push_api_key'] }\n```\n```bash\nprintf '%s' \"$KEY\" | vxil secrets set functions/push_api_key\n```\n\nIt resolves per invocation as `envelope.secrets.push_api_key`, so **rotating it needs no redeploy**. One\nwarning: resolution is **fail-closed**. A declared `secret:<name>` ref with no stored value makes every\ninvocation `409 secret_missing` before your code runs \u2014 which is why this blueprint declares no secrets\nat all by default. Declare one only when you will actually set it.\n\n## What to learn from this\n\n- **The hard part of push is ownership, not delivery.** A `unique` field plus one re-own path is the\n whole difference between a registry that stays correct and one that leaks notifications between users.\n- **A verified principal beats a body field, always.** The platform hands the function `end_user.id`; the\n request body is a suggestion.\n- **Delete on the verdict, not on the error.** `DeviceNotRegistered` is the only signal that justifies\n removing a device. Everything else is about the message.\n- **What an end-user cannot do, a server-mode hook can \u2014 on the user's request.** The claim row is the\n request; the `cmsHook` is the authority. No backend of your own is needed for it.\n- **A cron that cannot finish in one tick must remember where it stopped.** Restarting from the top of\n a newest-first list means the oldest rows are never reached.\n- **One function, several front doors.** `triggers: [...]` puts an http door, a hook and a cron on the\n same bundle \u2014 the code that owns a job keeps all of its entry points.\n\n**Pairs with:** the **alerts-to-slack** blueprint for the audit-stream drain used in\nexample (b).\n",
|
|
21068
21180
|
"functions": {
|
|
21069
21181
|
"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",
|
|
21070
21182
|
"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",
|
|
21071
|
-
"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"
|
|
21183
|
+
"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 // Server keys and the cron only: an end user's session must never fan push\n // out to arbitrary user_ids (a self-spam / harassment path).\n if ((env as Exclude<Env, CronFunctionEnvelope>).end_user) return json({ error: 'send-push is server-only' }, 403);\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"
|
|
21072
21184
|
}
|
|
21073
21185
|
},
|
|
21074
21186
|
{
|
|
@@ -23214,6 +23326,8 @@ async function runApply(apply, sel = "prod", opts = {}) {
|
|
|
23214
23326
|
const compared = newComparedCounts({ apiState: !!opts.apiState });
|
|
23215
23327
|
const ignore = apply ? [] : loadDriftIgnore();
|
|
23216
23328
|
const ignoredKeys = [];
|
|
23329
|
+
const informationalKeys = [];
|
|
23330
|
+
const strictRuntime = hasFlag("strict-runtime");
|
|
23217
23331
|
const count = (key) => {
|
|
23218
23332
|
if (isIgnored(key, ignore)) {
|
|
23219
23333
|
report.ignored++;
|
|
@@ -23472,7 +23586,10 @@ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared func
|
|
|
23472
23586
|
if (apply && r.webhookSubscriptions && (r.webhookSubscriptions.created || r.webhookSubscriptions.deleted)) {
|
|
23473
23587
|
console.log(` \u2713 webhook-trigger subscription reconciled (+${r.webhookSubscriptions.created} / -${r.webhookSubscriptions.deleted})`);
|
|
23474
23588
|
}
|
|
23475
|
-
|
|
23589
|
+
const fnDrift = partitionFnDrift(r.changes, { gate, strict: strictRuntime, ignore });
|
|
23590
|
+
for (const key of fnDrift.count) count(key);
|
|
23591
|
+
report.informational = (report.informational ?? 0) + fnDrift.informational.length;
|
|
23592
|
+
informationalKeys.push(...fnDrift.informational);
|
|
23476
23593
|
compared.functions += Object.keys(cfg.functions).length + r.changes.filter((c) => c.kind === "remote-only" || c.kind === "delete").length;
|
|
23477
23594
|
if (apply && r.hookReconcile && r.hookReconcile.failed > 0) {
|
|
23478
23595
|
const why = r.hookReconcile.warning ?? `${r.hookReconcile.failed} trigger subscription(s) could not be written`;
|
|
@@ -23504,7 +23621,6 @@ functions: skipped (--skip-functions) \u2014 ${declaredFns.length} declared func
|
|
|
23504
23621
|
});
|
|
23505
23622
|
}
|
|
23506
23623
|
const strictSecrets = hasFlag("strict-secrets");
|
|
23507
|
-
const informationalKeys = [];
|
|
23508
23624
|
if (!apply && (gate || explain)) {
|
|
23509
23625
|
await runSection("secrets", async () => {
|
|
23510
23626
|
const refs = collectSecretRefs(cfg);
|
|
@@ -23591,6 +23707,7 @@ api state \u2014 ${self.target.tenantSlug ?? "(env key)"} vs ${other.target.tena
|
|
|
23591
23707
|
informational_keys: [...informationalKeys, ...declaredApiStateKeys],
|
|
23592
23708
|
strict_secrets: strictSecrets,
|
|
23593
23709
|
strict_api_state: strictApiState,
|
|
23710
|
+
strict_runtime: strictRuntime,
|
|
23594
23711
|
...opts.apiState ? { api_state: apiStateCounts } : {}
|
|
23595
23712
|
}));
|
|
23596
23713
|
} else {
|
|
@@ -25796,7 +25913,7 @@ export default defineConfig(${JSON.stringify({ features: featBlocks, cms: { coll
|
|
|
25796
25913
|
architect "<describe your app>" \u2014 plain English in, a reviewed vxil.config.ts draft out
|
|
25797
25914
|
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>]
|
|
25798
25915
|
push production overrides: --allow-mock-in-prod \xB7 --allow-sandbox-in-prod \xB7 --allow-dev-origins-in-prod \xB7 --yes
|
|
25799
|
-
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)
|
|
25916
|
+
diff [--against <target>|--exit-code] [--explain] [--ignore <k,\u2026>] [--strict-secrets] [--strict-runtime] [--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; a function whose only change is the platform runtime / plan CPU limit informational unless --strict-runtime; declared policies/subscriptions/templates/roles/search collections compared to live, an undeclared live row informational unless --strict-api-state)
|
|
25800
25917
|
doctor [--dev|--target <name>] \u2014 + secrets referenced-vs-stored (advisory) + per-function served/published/local hash + keys with last-used (stored session)
|
|
25801
25918
|
versions <feature> \xB7 rollback <feature> --to <v>
|
|
25802
25919
|
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
|
|
@@ -26666,7 +26783,8 @@ function migrateUsage() {
|
|
|
26666
26783
|
changed since then (+ --prune: delete what the source no longer has)
|
|
26667
26784
|
live-app migration (supabase): [--with-password-hashes] keep every password (bcrypt,
|
|
26668
26785
|
rehashed at each user's first sign-in) \xB7 [--oidc-issuer https://<ref>.supabase.co/auth/v1]
|
|
26669
|
-
link users for the installed-app session hand-over
|
|
26786
|
+
link users for the installed-app session hand-over (the dry run previews the credentials
|
|
26787
|
+
step on the server: created/kept/skipped by reason, nothing written) \xB7 [--file-links shared-link] stored
|
|
26670
26788
|
public file URLs become stable shared links instead of object ids
|
|
26671
26789
|
\xB7 [--with-oauth-identities] carry the Google/Apple/GitHub/Facebook sign-in links (needs
|
|
26672
26790
|
--with-password-hashes in the same run) \xB7 [--keep-timestamps] keep each row's source
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The vxil CLI — init, quickstart, push, gen, secrets, keys, migrate, doctor, diff, functions, payments; installs the `vxil` command (npm i -g @vxil/cli).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@vxil/config": "0.
|
|
49
|
-
"@vxil/feature-configs": "0.
|
|
48
|
+
"@vxil/config": "0.20.0",
|
|
49
|
+
"@vxil/feature-configs": "0.18.0",
|
|
50
50
|
"@vxil/runtime": "0.0.1",
|
|
51
51
|
"@vxil/types": "0.0.1",
|
|
52
52
|
"miniflare": "^4.20260730.0"
|