openvibers/openvibe.vip Commits
VIP moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G2), async from end to end.
Changes
@@@ -6,6 +6,14 @@ NODE_ENV=developmentPORT=4620HOST=127.0.0.1BASE_URL=http://localhost:4620++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh vip: the runtime role through PgBouncer,++# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=++# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.VIP_DB_PATH=./data/vip.db# TRUST_PROXY=1@@@ -9,6 +9,9 @@ jobs:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:contracts-service: vip++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'syntax-dirs: server scripts client# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).security:@@@ -29,7 +29,7 @@ The nine authority tables (VIP is their only writer):|---|---|| `vip_creators` | a creator (a Network `usr_` subject, with a cached handle for `/:username`) or the network itself || `vip_plans` | a plan: status (draft/published/archived), the Billing product it is sold as, its current version |--| `vip_plan_versions` | **immutable** plan terms, one row per edit (SQLite triggers refuse UPDATE/DELETE except the one-time publish stamp) |++| `vip_plan_versions` | **immutable** plan terms, one row per edit (database triggers refuse UPDATE/DELETE except the one-time publish stamp) || `vip_perks` | perk definitions (creator or network scope): badge, emote, gated content, room, role, other || `vip_plan_perks` | **immutable**: which perks a plan *version* includes, with the key and name it was published with || `vip_product_bindings` | how a perk shows up in a product (`live chat_badge`, `chat badge`, `blog gated_post`, …) |@@@ -60,8 +60,9 @@ home that is not a cache.- **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages, identityresolve (importer only).- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.--- **openvibe-contracts** v0.49.0, **openvibe-sdk** v0.12.0 (outbox, inbox, delivery signatures, per-actor-- limits), **openvibe-shared** v1.25.0 (chrome, legal pages, release manifest and update metrics, metrics,++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).++- **openvibe-contracts** v0.76.0, **openvibe-sdk** v0.20.1 (outbox, inbox, delivery signatures, per-actor++ limits), **openvibe-shared** v1.27.0 (Frame, legal pages, release manifest and update metrics, metrics,readiness).- Consumers, through the client below: Chat (the member badge), Community (members-only spaces andthreads), Blog (members-only posts) and Wiki (VIP spaces and pages); Live is not wired yet.@@@ -119,7 +120,7 @@ The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) is no### The entitlement projection (Billing is the truth)`vip_entitlement_projection` is a cache of Billing's entitlements, fed by Billing's events--(signed Events webhook + openvibe-sdk `createInbox`, exactly once) and by direct checks.++(signed Events webhook + openvibe-sdk `createPgInbox`, exactly once) and by direct checks.- `valid_until = min(paid period end, last word from Billing + VIP_PROJECTION_MAX_AGE_MS)`; a "no"is kept for the max age.@@@ -386,7 +387,11 @@ on the host (Billing is in shadow and has sent VIP no events). The Live import rProduction deploys with `sudo ovhost deploy vip` on the host (strategy `git-checkout`: fetch,fast-forward `/opt/openvibe.vip`, install on a lockfile change, restart, wait for `/api/ready`).--The unit is `openvibe-vip.service` on `127.0.0.1:4620`, the env file `/etc/openvibe/vip.env`. State lives in++The unit is `openvibe-vip.service` on `127.0.0.1:4620`, the env file `/etc/openvibe/vip.env`. The database is++`ov_vip` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh vip` writes its settings); the++release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk++`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old++`/var/lib/openvibe-vip/vip.db` stays read-only for 7 days as the rollback. State lives in`/var/lib/openvibe-vip`. The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) waitsfor the launch: `openvibe.vip` serves the Sites placeholder.Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the@@@ -4,7 +4,7 @@"code": true,"deployed": true,"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 loopback only; https://openvibe.vip still serves the OpenVibe.Sites placeholder. Principal vip; three Events subscriptions for Billing's events (secret rotated 2026-09-24); checkout closed (VIP_CHECKOUT_PROVIDERS empty). On 2026-09-24 production held the Live import: 44 creators, 43 plans (one per creator), 0 memberships while Billing runs in shadow.",-- "contracts": "openvibe-contracts v0.49.0",++ "contracts": "openvibe-contracts v0.76.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","roadmap": "Wave 10","works": [@@@ -41,5 +41,6 @@"Live subscription import","SDK outbox events vip.plan.published and vip.membership.changed","per-actor limits (openvibe-sdk/limits)"-- ]++ ],++ "runtime": ""}@@@ -2,7 +2,7 @@Description=OpenVibe.VIP — memberships: plans, versioned terms, perks and gated-resource policy# Network signs the tokens VIP verifies (its key is fetched at boot and retried); Billing holds# the entitlement truth; Events relays the outbox when EVENTS_URL is set. VIP starts without any.--After=network.target++After=network.target postgresql.service pgbouncer.service valkey-server.serviceWants=openvibe-network.service openvibe-billing.service[Service]nnew file mode 100644@@@ -0,0 +1,237 @@++-- phase: expand++-- OpenVibe.VIP on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.++++CREATE TABLE settings (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY CHECK (id = 1),++ created_at text COLLATE "C" NOT NULL++);++++CREATE TABLE vip_creators (++ id text COLLATE "C" PRIMARY KEY,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('creator', 'network')),++ subject text COLLATE "C" UNIQUE,++ username text COLLATE "C",++ username_lc text COLLATE "C" UNIQUE,++ display_name text COLLATE "C",++ bio text COLLATE "C",++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),++ origin text COLLATE "C" NOT NULL DEFAULT 'self' CHECK (origin IN ('self', 'service', 'import', 'staff', 'system')),++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ CHECK ((kind = 'network' AND subject IS NULL) OR (kind = 'creator' AND subject IS NOT NULL))++);++++CREATE TABLE vip_plans (++ id text COLLATE "C" PRIMARY KEY,++ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),++ slug text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),++ billing_kind text COLLATE "C" CHECK (billing_kind IS NULL OR billing_kind IN ('channel_subscription')),++ current_version_id text COLLATE "C",++ latest_version bigint NOT NULL DEFAULT 0,++ sort bigint NOT NULL DEFAULT 0,++ created_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ archived_at text COLLATE "C",++ UNIQUE (creator_id, slug)++);++-- Billing sells one channel subscription per (subscriber, creator) today, so a creator can have at++-- most one published plan bound to it.++CREATE UNIQUE INDEX idx_vip_plans_one_billing_product++ ON vip_plans (creator_id, billing_kind) WHERE status = 'published' AND billing_kind IS NOT NULL;++++CREATE TABLE vip_plan_versions (++ id text COLLATE "C" PRIMARY KEY,++ plan_id text COLLATE "C" NOT NULL REFERENCES vip_plans(id),++ version bigint NOT NULL,++ name text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ benefits text COLLATE "C" NOT NULL DEFAULT '[]',++ terms text COLLATE "C" NOT NULL,++ change_note text COLLATE "C",++ created_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ published_at text COLLATE "C",++ UNIQUE (plan_id, version)++);++CREATE FUNCTION vip_plan_versions_no_rewrite() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'vip_plan_versions are immutable: edit a plan by creating a new version'; END $$;++CREATE TRIGGER vip_plan_versions_no_rewrite BEFORE UPDATE ON vip_plan_versions FOR EACH ROW WHEN (NEW.id IS DISTINCT FROM OLD.id OR NEW.plan_id IS DISTINCT FROM OLD.plan_id OR NEW.version IS DISTINCT FROM OLD.version OR NEW.name IS DISTINCT FROM OLD.name OR NEW.description IS DISTINCT FROM OLD.description OR NEW.benefits IS DISTINCT FROM OLD.benefits OR NEW.terms IS DISTINCT FROM OLD.terms OR NEW.change_note IS DISTINCT FROM OLD.change_note OR NEW.created_by IS DISTINCT FROM OLD.created_by OR NEW.created_at IS DISTINCT FROM OLD.created_at OR (OLD.published_at IS NOT NULL AND NEW.published_at IS DISTINCT FROM OLD.published_at)) EXECUTE FUNCTION vip_plan_versions_no_rewrite();++CREATE FUNCTION vip_plan_versions_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'vip_plan_versions are immutable: edit a plan by creating a new version'; END $$;++CREATE TRIGGER vip_plan_versions_no_delete BEFORE DELETE ON vip_plan_versions FOR EACH ROW EXECUTE FUNCTION vip_plan_versions_no_delete();++++CREATE TABLE vip_perks (++ id text COLLATE "C" PRIMARY KEY,++ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),++ key text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ kind text COLLATE "C" NOT NULL DEFAULT 'other' CHECK (kind IN ('badge', 'emote', 'gated_content', 'room', 'role', 'other')),++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),++ created_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ UNIQUE (creator_id, key)++);++++CREATE TABLE vip_plan_perks (++ plan_version_id text COLLATE "C" NOT NULL REFERENCES vip_plan_versions(id),++ perk_id text COLLATE "C" NOT NULL REFERENCES vip_perks(id),++ perk_key text COLLATE "C" NOT NULL,++ perk_name text COLLATE "C" NOT NULL,++ position bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (plan_version_id, perk_id)++);++CREATE INDEX idx_vip_plan_perks_key ON vip_plan_perks (plan_version_id, perk_key);++CREATE FUNCTION vip_plan_perks_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'vip_plan_perks belong to an immutable plan version'; END $$;++CREATE TRIGGER vip_plan_perks_no_update BEFORE UPDATE ON vip_plan_perks FOR EACH ROW EXECUTE FUNCTION vip_plan_perks_no_update();++CREATE FUNCTION vip_plan_perks_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'vip_plan_perks belong to an immutable plan version'; END $$;++CREATE TRIGGER vip_plan_perks_no_delete BEFORE DELETE ON vip_plan_perks FOR EACH ROW EXECUTE FUNCTION vip_plan_perks_no_delete();++++CREATE TABLE vip_product_bindings (++ id text COLLATE "C" PRIMARY KEY,++ perk_id text COLLATE "C" NOT NULL REFERENCES vip_perks(id),++ product text COLLATE "C" NOT NULL,++ binding text COLLATE "C" NOT NULL,++ config text COLLATE "C" NOT NULL DEFAULT '{}',++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')),++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ UNIQUE (perk_id, product, binding)++);++++CREATE TABLE vip_member_preferences (++ member_subject text COLLATE "C" NOT NULL,++ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),++ show_badge bigint NOT NULL DEFAULT 1,++ listed bigint NOT NULL DEFAULT 0,++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (member_subject, creator_id)++);++++CREATE TABLE vip_gated_resource_rules (++ id text COLLATE "C" PRIMARY KEY,++ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),++ resource_service text COLLATE "C" NOT NULL,++ resource_type text COLLATE "C" NOT NULL,++ resource_id text COLLATE "C" NOT NULL,++ requirement text COLLATE "C" NOT NULL CHECK (requirement IN ('member', 'plan', 'perk')),++ plan_id text COLLATE "C" REFERENCES vip_plans(id),++ perk_key text COLLATE "C",++ sensitive bigint NOT NULL DEFAULT 0,++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),++ created_by text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),++ CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)++);++-- One active rule per (resource, creator): the product names the owner when it asks (policies.js).++DROP INDEX IF EXISTS idx_vip_rules_resource;++CREATE UNIQUE INDEX idx_vip_rules_resource_creator++ ON vip_gated_resource_rules (resource_service, resource_type, resource_id, creator_id) WHERE status = 'active';++++CREATE TABLE vip_migration_maps (++ source text COLLATE "C" NOT NULL,++ source_table text COLLATE "C" NOT NULL,++ source_id text COLLATE "C" NOT NULL,++ target_type text COLLATE "C",++ target_id text COLLATE "C",++ status text COLLATE "C" NOT NULL CHECK (status IN ('imported', 'linked', 'held', 'excluded')),++ reason text COLLATE "C",++ run_id text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (source, source_table, source_id)++);++++CREATE TABLE vip_memberships (++ id text COLLATE "C" PRIMARY KEY,++ member_subject text COLLATE "C" NOT NULL,++ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),++ plan_id text COLLATE "C" REFERENCES vip_plans(id),++ plan_version_id text COLLATE "C" REFERENCES vip_plan_versions(id),++ billing_subscription_id text COLLATE "C",++ origin text COLLATE "C" NOT NULL CHECK (origin IN ('checkout', 'billing', 'import')),++ joined_at text COLLATE "C" NOT NULL,++ terms_since text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ UNIQUE (member_subject, creator_id)++);++CREATE INDEX idx_vip_memberships_creator ON vip_memberships (creator_id);++++CREATE TABLE vip_entitlement_projection (++ member_subject text COLLATE "C" NOT NULL,++ creator_subject text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL,++ active bigint NOT NULL,++ expires_at text COLLATE "C",++ cancel_at_period_end bigint NOT NULL DEFAULT 0,++ subscription_id text COLLATE "C",++ subscription_status text COLLATE "C",++ source text COLLATE "C" NOT NULL CHECK (source IN ('event', 'billing_check', 'import')),++ last_reason text COLLATE "C",++ billing_event_id text COLLATE "C",++ billing_as_of bigint NOT NULL,++ synced_at bigint NOT NULL,++ valid_until bigint NOT NULL,++ last_used_at bigint,++ PRIMARY KEY (member_subject, creator_subject, kind)++);++CREATE INDEX idx_vip_projection_creator ON vip_entitlement_projection (creator_subject, kind, active);++++CREATE TABLE vip_checkouts (++ id text COLLATE "C" PRIMARY KEY,++ member_subject text COLLATE "C" NOT NULL,++ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),++ plan_id text COLLATE "C" NOT NULL REFERENCES vip_plans(id),++ plan_version_id text COLLATE "C" NOT NULL REFERENCES vip_plan_versions(id),++ provider text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL CHECK (status IN ('created', 'handed_off', 'paid', 'failed', 'used')),++ billing_intent_id text COLLATE "C",++ billing_subscription_id text COLLATE "C",++ checkout_url text COLLATE "C",++ checkout_ref text COLLATE "C",++ error text COLLATE "C",++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++CREATE INDEX idx_vip_checkouts_member ON vip_checkouts (member_subject, creator_id, created_at);++++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled++CREATE TABLE IF NOT EXISTS idempotency_receipts (++ consumer text NOT NULL,++ event_id text NOT NULL,++ processed_at bigint NOT NULL,++ PRIMARY KEY (consumer, event_id)++);++++-- openvibe-sdk/events PostgreSQL outbox++CREATE TABLE IF NOT EXISTS event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;++++-- Added after the first SQLite schema (server/db.js COLUMNS): the creator's choice to show their member count.++ALTER TABLE vip_creators ADD COLUMN show_member_count bigint NOT NULL DEFAULT 1;++++-- The one settings row, and the network itself as the creator of network-wide plans and perks (an import replaces++-- both with the SQLite rows).++INSERT INTO settings (id, created_at) VALUES (1, to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ON CONFLICT DO NOTHING;++INSERT INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)++ VALUES ('network', 'network', NULL, NULL, NULL, 'OpenVibe', 'system',++ to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'))++ ON CONFLICT DO NOTHING;@@@ -13,15 +13,33 @@"cookie-parser": "^1.4.7","dotenv": "^16.4.7","express": "^4.21.2",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"},"engines": {"node": ">=22.0.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -223,6 +241,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -305,6 +332,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -714,6 +750,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },"node_modules/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -778,12 +857,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -983,9 +1074,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.49.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",++ "version": "0.76.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -999,18 +1090,38 @@}},"node_modules/openvibe-sdk": {-- "version": "0.12.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",++ "version": "0.20.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",++ "integrity": "sha512-3Zwz8sJhWhnuw9GYr6ieIDp9CIl6ug0QqJE5ifDsk4ckrethBjcDzjadE1GSCGQDFXXLf/+bVWtI7X3VOVXmFQ==","license": "MIT","engines": {"node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }}},"node_modules/openvibe-shared": {-- "version": "1.25.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",-- "integrity": "sha512-oqZ+PvljrWi8IEbtg6bDYai17ZV2sALFx/ezMptniELSr10TgFUiGUQt+b1IWwiHv0T2Y1U2iSrkirJxMNgjSw==",++ "version": "1.27.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",++ "integrity": "sha512-/H7dyFw+cURcdjyfbpyY7mNc6F+Pcb9TwkVwZHR+9d3M62q70KDhZLo/gZi71eaYxUjD8lO8tmbXu70EAa9paQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1031,6 +1142,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1154,6 +1393,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1369,6 +1629,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1496,6 +1771,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -2,7 +2,7 @@"name": "openvibe-vip","version": "0.1.0","private": true,-- "description": "OpenVibe.VIP — memberships on the OpenVibe network: plans, versioned plan terms, perks and gated-resource policy. Billing holds the entitlement truth; VIP holds plans and perks.",++ "description": "OpenVibe.VIP \u2014 memberships on the OpenVibe network: plans, versioned plan terms, perks and gated-resource policy. Billing holds the entitlement truth; VIP holds plans and perks.","main": "server/index.js","exports": {".": "./server/index.js",@@@ -14,9 +14,15 @@"dev": "NODE_ENV=development node server/index.js","test": "node test/run.js","subscribe": "node scripts/subscribe.js",-- "import-live": "node scripts/import-live.js"++ "import-live": "node scripts/import-live.js",++ "test:pg": "VIP_TEST_STORE=pg node test/run.js"},-- "keywords": ["openvibe", "vip", "memberships", "entitlements"],++ "keywords": [++ "openvibe",++ "vip",++ "memberships",++ "entitlements"++ ],"author": "OpenVibers","license": "AGPL-3.0-only","repository": "github:OpenVibers/OpenVibe.VIP",@@@ -25,12 +31,17 @@"cookie-parser": "^1.4.7","dotenv": "^16.4.7","express": "^4.21.2",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",++ "pg": "^8.23.0"},"engines": {"node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"}}nnew file mode 100644@@@ -0,0 +1,26 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of VIP's SQLite database (VIP_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is++ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).++ *++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]++ *++ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts++ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its++ * name and columns.++ */++require('dotenv').config();++const { runSqliteMigration } = require('openvibe-sdk/db');++const configLib = require('../server/config');++const { MIGRATIONS } = require('../server/db');++++const TABLES = {};++++if (require.main === module) {++ const config = configLib.loadConfig();++ runSqliteMigration({ service: 'vip', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });++}++++module.exports = { TABLES };@@@ -15,7 +15,7 @@* (/embed, public pages behind nginx), and the signed Events deliveries at /internal/events (Billing's membership changes:* Events pushes at its own pace, and a 429 would only make it retry and fall behind).*/--const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');function actor(req) {const p = req.principal;@@@ -28,7 +28,7 @@ function actor(req) {* limits(name, own) middleware for one app, plus limits.reads(name, skip): the defaults on GET/HEAD* requests, except those `skip(req)` names (routes that set their own).*/--function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {const refused = registry? registry.counter({ name: 'vip_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] }): null;@@@ -36,6 +36,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lolimits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },actor,now,++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),onLimited(e) {// The actor is a principal, a subject id or an address, never a token.log.warn(`[VIP] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);@@@ -47,36 +47,36 @@ function v1Router({ domain, apiAuth, limits }) {const needAuth = (p) => { if (p.kind === 'anonymous') fail(401, 'auth.required', 'a Network service or user token is required'); };/** A creator named by SubjectRef, usr_ id, username or 'network'. */-- function findCreator(ref) {-- if (ref === 'network') return creators.network();-- if (ref && typeof ref === 'object') return creators.bySubject(userSubject(ref, 'creator'));++ async function findCreator(ref) {++ if (ref === 'network') return await creators.network();++ if (ref && typeof ref === 'object') return await creators.bySubject(userSubject(ref, 'creator'));const s = String(ref || '');-- if (isUserSubject(s)) return creators.bySubject(s);-- return creators.byUsername(s);++ if (isUserSubject(s)) return await creators.bySubject(s);++ return await creators.byUsername(s);}/*** The creator a write acts for. Users: themselves (created on first use), or `network` for staff.* Services (holding `cap`): the creator named in the body.*/-- function actingCreator(req, ref, cap) {++ async function actingCreator(req, ref, cap) {const p = req.principal;needAuth(p);if (p.kind === 'service') {if (!granted(p, cap)) denied(cap);-- if (ref === 'network') return creators.network();++ if (ref === 'network') return await creators.network();if (!ref) fail(422, 'vip.invalid_input', 'creator is required ({ type: "user", id } or "network")');-- return creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'service' });++ return await creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'service' });}if (ref === 'network') {if (!isStaff(p)) denied();-- return creators.network();++ return await creators.network();}if (ref && (typeof ref === 'object' ? ref.id : ref) !== p.subject) {if (!isStaff(p)) fail(403, 'vip.not_yours', 'you can only manage your own plans');-- return creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'staff' });++ return await creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'staff' });}-- return creators.ensure({ subject: p.subject, username: p.username, displayName: p.name, origin: 'self' });++ return await creators.ensure({ subject: p.subject, username: p.username, displayName: p.name, origin: 'self' });}/** May the principal manage things owned by creator row `c`? */@@@ -100,104 +100,104 @@ function v1Router({ domain, apiAuth, limits }) {return userSubject(value, 'subject');}-- const presentPlanFull = (plan) => ({ ...plans.present(plan), creator: creators.present(creators.byId(plan.creator_id)) });++ const presentPlanFull = async (plan) => ({ ...await plans.present(plan), creator: creators.present(await creators.byId(plan.creator_id)) });const trace = (req) => req.ov && req.ov.traceparent;// ── Creators (public) ────────────────────────────────────-- r.get('/creators/:ref', wrap((req, res) => {-- const c = findCreator(req.params.ref);++ r.get('/creators/:ref', wrap(async (req, res) => {++ const c = await findCreator(req.params.ref);if (!c) fail(404, 'vip.creator_not_found', 'no such creator');const own = mayManage(req.principal, c, CAP.planList);res.json({creator: creators.present(c),-- plans: plans.list({ creatorId: c.id, includeDrafts: own }).map((p) => plans.present(p)),-- perks: perks.list({ creatorId: c.id, includeNetwork: false }).map((p) => perks.present(p)),++ plans: (await Promise.all((await plans.list({ creatorId: c.id, includeDrafts: own })).map(async (p) => await plans.present(p)))),++ perks: (await Promise.all((await perks.list({ creatorId: c.id, includeNetwork: false })).map(async (p) => await perks.present(p)))),});}));/** The public membership card (server/domain/cards.js): what /embed/:username/card.json serves. */r.get('/creators/:ref/card', wrap(async (req, res) => {-- const card = await domain.cards.card(findCreator(req.params.ref));++ const card = await domain.cards.card(await findCreator(req.params.ref));if (!card) fail(404, 'vip.creator_not_found', 'no public membership card for this creator');res.json({ card });}));// ── Plans ────────────────────────────────────────────────-- r.get('/plans', wrap((req, res) => {-- const c = findCreator(req.query.creator);++ r.get('/plans', wrap(async (req, res) => {++ const c = await findCreator(req.query.creator);if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');const include = String(req.query.include || '').split(',');const own = mayManage(req.principal, c, CAP.planList);-- const list = plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });-- res.json({ creator: creators.present(c), plans: list.map((p) => plans.present(p)) });++ const list = await plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });++ res.json({ creator: creators.present(c), plans: (await Promise.all(list.map(async (p) => await plans.present(p)))) });}));-- const visiblePlan = (req, id) => {-- const plan = plans.byId(id);++ const visiblePlan = async (req, id) => {++ const plan = await plans.byId(id);if (!plan) fail(404, 'vip.plan_not_found', `no plan ${id}`);-- if (plan.status !== 'published' && !mayManage(req.principal, creators.byId(plan.creator_id), CAP.planList)) fail(404, 'vip.plan_not_found', `no plan ${id}`);++ if (plan.status !== 'published' && !mayManage(req.principal, await creators.byId(plan.creator_id), CAP.planList)) fail(404, 'vip.plan_not_found', `no plan ${id}`);return plan;};-- r.get('/plans/:id', wrap((req, res) => res.json({ plan: presentPlanFull(visiblePlan(req, req.params.id)) })));-- r.get('/plans/:id/versions', wrap((req, res) => {-- const plan = visiblePlan(req, req.params.id);-- const own = mayManage(req.principal, creators.byId(plan.creator_id), CAP.planList);-- const all = plans.versions(plan.id).filter((v) => own || v.published_at).map(plans.presentVersion);++ r.get('/plans/:id', wrap(async (req, res) => res.json({ plan: await presentPlanFull(await visiblePlan(req, req.params.id)) })));++ r.get('/plans/:id/versions', wrap(async (req, res) => {++ const plan = await visiblePlan(req, req.params.id);++ const own = mayManage(req.principal, await creators.byId(plan.creator_id), CAP.planList);++ const all = (await Promise.all((await plans.versions(plan.id)).filter((v) => own || v.published_at).map(plans.presentVersion)));res.json({ plan_id: plan.id, versions: all });}));-- r.post('/plans', create, wrap((req, res) => {++ r.post('/plans', create, wrap(async (req, res) => {const b = req.body || {};-- const c = actingCreator(req, b.creator, CAP.planCreate);++ const c = await actingCreator(req, b.creator, CAP.planCreate);const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;-- const plan = plans.create({++ const plan = await plans.create({creatorId: c.id, slug: b.slug, name: b.name, description: b.description, benefits: b.benefits, perks: b.perks,billingKind: b.billing_kind, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),});-- res.status(201).json({ plan: presentPlanFull(plan) });++ res.status(201).json({ plan: await presentPlanFull(plan) });}));-- const managedPlan = (req, cap) => {-- const plan = plans.byId(req.params.id);++ const managedPlan = async (req, cap) => {++ const plan = await plans.byId(req.params.id);if (!plan) fail(404, 'vip.plan_not_found', `no plan ${req.params.id}`);-- requireManage(req, creators.byId(plan.creator_id), cap);++ requireManage(req, await creators.byId(plan.creator_id), cap);return plan;};-- r.patch('/plans/:id', edit, wrap((req, res) => {-- const plan = managedPlan(req, CAP.planUpdate);++ r.patch('/plans/:id', edit, wrap(async (req, res) => {++ const plan = await managedPlan(req, CAP.planUpdate);const b = req.body || {};const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;-- const out = plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });-- res.json({ plan: presentPlanFull(out.plan), version: plans.presentVersion(out.version), unchanged: out.unchanged });++ const out = await plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });++ res.json({ plan: await presentPlanFull(out.plan), version: await plans.presentVersion(out.version), unchanged: out.unchanged });}));-- r.post('/plans/:id/publish', create, wrap((req, res) => {-- const plan = managedPlan(req, CAP.planUpdate);-- const out = plans.publish(plan.id, { traceparent: trace(req) });-- res.json({ plan: presentPlanFull(out.plan), replay: out.replay });++ r.post('/plans/:id/publish', create, wrap(async (req, res) => {++ const plan = await managedPlan(req, CAP.planUpdate);++ const out = await plans.publish(plan.id, { traceparent: trace(req) });++ res.json({ plan: await presentPlanFull(out.plan), replay: out.replay });}));-- r.post('/plans/:id/archive', create, wrap((req, res) => {-- const plan = managedPlan(req, CAP.planArchive);-- res.json({ plan: presentPlanFull(plans.archive(plan.id)) });++ r.post('/plans/:id/archive', create, wrap(async (req, res) => {++ const plan = await managedPlan(req, CAP.planArchive);++ res.json({ plan: await presentPlanFull(await plans.archive(plan.id)) });}));// ── Perks ────────────────────────────────────────────────-- r.get('/perks', wrap((req, res) => {-- const c = findCreator(req.query.creator);++ r.get('/perks', wrap(async (req, res) => {++ const c = await findCreator(req.query.creator);if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');const own = mayManage(req.principal, c, CAP.perkList);-- const list = perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });-- res.json({ perks: list.map((p) => perks.present(p)) });++ const list = await perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });++ res.json({ perks: (await Promise.all(list.map(async (p) => await perks.present(p)))) });}));-- r.post('/perks', create, wrap((req, res) => {++ r.post('/perks', create, wrap(async (req, res) => {const b = req.body || {};-- const c = actingCreator(req, b.creator, CAP.perkCreate);++ const c = await actingCreator(req, b.creator, CAP.perkCreate);const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;-- const p = perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });-- res.status(201).json({ perk: perks.present(p) });++ const p = await perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });++ res.status(201).json({ perk: await perks.present(p) });}));-- r.patch('/perks/:id', edit, wrap((req, res) => {-- const p = perks.byId(req.params.id);++ r.patch('/perks/:id', edit, wrap(async (req, res) => {++ const p = await perks.byId(req.params.id);if (!p) fail(404, 'vip.perk_not_found', `no perk ${req.params.id}`);-- requireManage(req, creators.byId(p.creator_id), CAP.perkUpdate);++ requireManage(req, await creators.byId(p.creator_id), CAP.perkUpdate);const b = req.body || {};-- res.json({ perk: perks.present(perks.update(p.id, { name: b.name, description: b.description, kind: b.kind, status: b.status, bindings: b.bindings })) });++ res.json({ perk: await perks.present(await perks.update(p.id, { name: b.name, description: b.description, kind: b.kind, status: b.status, bindings: b.bindings })) });}));// ── Checkout hand-off (Billing) ──────────────────────────@@@ -233,29 +233,29 @@ function v1Router({ domain, apiAuth, limits }) {const member = memberSubject(req, req.params.subject, CAP.status);const mode = ['projection', 'authoritative'].includes(req.query.mode) ? req.query.mode : 'auto';const list = [];-- for (const m of memberships.forMember(member)) {-- const c = creators.byId(m.creator_id);++ for (const m of await memberships.forMember(member)) {++ const c = await creators.byId(m.creator_id);const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req) }) : null;-- list.push({ ...memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: memberships.preferences(member, m.creator_id) });++ list.push({ ...await memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: await memberships.preferences(member, m.creator_id) });}res.json({ member: { type: 'user', id: member }, memberships: list });}));-- const creatorParam = (req) => {-- const c = findCreator(req.params.creator);++ const creatorParam = async (req) => {++ const c = await findCreator(req.params.creator);if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');return c;};// Cancelling is handed to Billing: the member does it once.r.post('/memberships/:creator/cancel', limits('vip.membership.cancel', { minute: 5, hour: 30 }), wrap(async (req, res) => {if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');-- const c = creatorParam(req);++ const c = await creatorParam(req);res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));}));-- r.put('/memberships/:creator/preferences', edit, wrap((req, res) => {++ r.put('/memberships/:creator/preferences', edit, wrap(async (req, res) => {if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');-- const c = creatorParam(req);++ const c = await creatorParam(req);const b = req.body || {};-- res.json({ preferences: memberships.setPreferences(req.principal.subject, c.id, { showBadge: b.show_badge === undefined ? undefined : bool(b.show_badge), listed: b.listed === undefined ? undefined : bool(b.listed) }) });++ res.json({ preferences: await memberships.setPreferences(req.principal.subject, c.id, { showBadge: b.show_badge === undefined ? undefined : bool(b.show_badge), listed: b.listed === undefined ? undefined : bool(b.listed) }) });}));// ── Entitlement check (projection + authoritative fallback) ──@@@ -266,7 +266,7 @@ function v1Router({ domain, apiAuth, limits }) {const raw = src.creator && typeof src.creator === 'object' ? src.creator.id : src.creator;let creatorSubject = isUserSubject(raw) ? raw : null;if (!creatorSubject) {-- const c = findCreator(raw);++ const c = await findCreator(raw);if (!c || !c.subject) fail(422, 'vip.invalid_input', 'creator must be a user SubjectRef, usr_ id or VIP username');creatorSubject = c.subject;}@@@ -278,9 +278,9 @@ function v1Router({ domain, apiAuth, limits }) {const product = String(src.product);if (!/^[a-z][a-z0-9-]{1,39}$/.test(product)) fail(422, 'vip.invalid_input', 'product must be a service id such as chat or blog');out.product = product;-- out.product_perks = out.active ? perks.forMembership(out.membership, product) : [];-- const c = creators.bySubject(creatorSubject);-- out.preferences = c ? memberships.preferences(member, c.id) : { show_badge: true, listed: false };++ out.product_perks = out.active ? await perks.forMembership(out.membership, product) : [];++ const c = await creators.bySubject(creatorSubject);++ out.preferences = c ? await memberships.preferences(member, c.id) : { show_badge: true, listed: false };}res.json(out);});@@@ -293,44 +293,44 @@ function v1Router({ domain, apiAuth, limits }) {r.post('/entitlements/check', entitlementLimit, entitlementCheck);// ── Gated-resource policy ────────────────────────────────-- r.get('/policies', wrap((req, res) => {++ r.get('/policies', wrap(async (req, res) => {const p = req.principal;needAuth(p);if (req.query.service || req.query.type || req.query.id) {const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });// Rules are per creator: name the owner (services must; a person defaults to themselves).-- const owner = req.query.owner ? policies.ownerCreator(req.query.owner === 'network' ? 'network' : String(req.query.owner))-- : (p.kind === 'user' ? creators.bySubject(p.subject) : null);++ const owner = req.query.owner ? await policies.ownerCreator(req.query.owner === 'network' ? 'network' : String(req.query.owner))++ : (p.kind === 'user' ? await creators.bySubject(p.subject) : null);if (!req.query.owner && p.kind !== 'user') fail(422, 'vip.invalid_input', 'owner (the creator\'s usr_ id or network) is required');-- const rule = owner ? policies.forResource(resource, owner.id) : null;++ const rule = owner ? await policies.forResource(resource, owner.id) : null;if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);-- if (!rule || (p.kind === 'user' && !mayManage(p, creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');-- return res.json({ rule: policies.present(rule) });++ if (!rule || (p.kind === 'user' && !mayManage(p, await creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');++ return res.json({ rule: await policies.present(rule) });}-- const c = findCreator(req.query.creator || (p.kind === 'user' ? p.subject : null));++ const c = await findCreator(req.query.creator || (p.kind === 'user' ? p.subject : null));if (!c) fail(404, 'vip.creator_not_found', 'no such creator');requireManage(req, c, CAP.policyGet);-- return res.json({ rules: policies.list(c.id).map(policies.present) });++ return res.json({ rules: await Promise.all((await policies.list(c.id)).map(policies.present)) });}));-- r.get('/policies/:id', wrap((req, res) => {-- const rule = policies.byId(req.params.id);++ r.get('/policies/:id', wrap(async (req, res) => {++ const rule = await policies.byId(req.params.id);if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);-- requireManage(req, creators.byId(rule.creator_id), CAP.policyGet);-- res.json({ rule: policies.present(rule) });++ requireManage(req, await creators.byId(rule.creator_id), CAP.policyGet);++ res.json({ rule: await policies.present(rule) });}));-- r.post('/policies', edit, wrap((req, res) => {++ r.post('/policies', edit, wrap(async (req, res) => {const b = req.body || {};-- const c = actingCreator(req, b.creator, CAP.policySet);++ const c = await actingCreator(req, b.creator, CAP.policySet);if (c.kind !== 'creator') fail(422, 'vip.invalid_input', 'gated resources belong to a creator');const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;-- const rule = policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });-- res.status(201).json({ rule: policies.present(rule) });++ const rule = await policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });++ res.status(201).json({ rule: await policies.present(rule) });}));-- r.delete('/policies/:id', edit, wrap((req, res) => {-- const rule = policies.byId(req.params.id);++ r.delete('/policies/:id', edit, wrap(async (req, res) => {++ const rule = await policies.byId(req.params.id);if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);-- requireManage(req, creators.byId(rule.creator_id), CAP.policySet);-- res.json({ rule: policies.present(policies.disable(rule.id)) });++ requireManage(req, await creators.byId(rule.creator_id), CAP.policySet);++ res.json({ rule: await policies.present(await policies.disable(rule.id)) });}));// Evaluation is the gates' hot path too (see the entitlement check).r.post('/policies/evaluate', byKind(limits('vip.resource.policy.evaluate', { minute: 1200, hour: 30000 }), limits('vip.resource.policy.evaluate')), wrap(async (req, res) => {@@@ -353,22 +353,22 @@ function v1Router({ domain, apiAuth, limits }) {// ── Creator members ──────────────────────────────────────r.get('/creators/:ref/members', wrap(async (req, res) => {-- const c = findCreator(req.params.ref);++ const c = await findCreator(req.params.ref);if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');requireManage(req, c, CAP.members);-- const byMember = new Map(memberships.forCreator(c.id).map((m) => [m.member_subject, m]));-- const row = (subject, extra) => ({ member: { type: 'user', id: subject }, ...extra, membership: memberships.present(byMember.get(subject)) });++ const byMember = new Map((await memberships.forCreator(c.id)).map((m) => [m.member_subject, m]));++ const row = async (subject, extra) => ({ member: { type: 'user', id: subject }, ...extra, membership: await memberships.present(byMember.get(subject)) });try {// Authoritative: Billing's active subscriptions to this creator.const out = await billing.listSubscriptions({ streamer: c.subject, status: 'active' });-- const members = (out.subscriptions || []).map((s) => row(s.subscriber.id, {++ const members = (await Promise.all((out.subscriptions || []).map(async (s) => await row(s.subscriber.id, {current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, subscription_id: s.id,-- }));++ }))));return res.json({ creator: creators.present(c), source: 'billing', members });} catch {-- const members = entitlements.projectedMembers(c.subject).map((pr) => row(pr.member_subject, {++ const members = (await Promise.all((await entitlements.projectedMembers(c.subject)).map(async (pr) => await row(pr.member_subject, {current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, subscription_id: pr.subscription_id,-- }));++ }))));return res.json({ creator: creators.present(c), source: 'projection', note: 'Billing did not answer; this list is VIP\'s projection (fresh rows only) and may be incomplete', members });}}));@@@ -378,9 +378,9 @@ function v1Router({ domain, apiAuth, limits }) {/** Async-safe handler: VipError → problem+json; anything else → 500. */function wrap(fn) {-- return (req, res, next) => {++ return async (req, res, next) => {try {-- const p = fn(req, res, next);++ const p = await fn(req, res, next);if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));} catch (e) { sendError(req, res, e); }};@@@ -39,9 +39,11 @@ const pages = require('./web/pages');const VERSION = require('../package.json').version;const PUBLIC_DIR = path.join(__dirname, '..', 'public');--function createApp(opts = {}) {++async function createApp(opts = {}) {const config = opts.config || loadConfig();-- const db = opts.db || openDb(config.dbPath);++ const log0 = opts.log || console;++ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a migrated handle.++ const db = opts.db || await openDb(config, { log: log0 });const fetchImpl = opts.fetchImpl || globalThis.fetch;const log = opts.log || console;const now = opts.now || (() => Date.now());@@@ -60,12 +62,12 @@ function createApp(opts = {}) {// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers// direct loopback callers only (Track O). Gauges: outbox backlog and projection staleness.const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'vip', release: release.release });-- metrics.registry.gauge({ name: 'vip_outbox_pending', help: 'Events waiting in the outbox', collect: () => outbox.outbox.pending() });++ metrics.registry.gauge({ name: 'vip_outbox_pending', help: 'Events waiting in the outbox', collect: async () => await outbox.outbox.pending() });metrics.registry.gauge({name: 'vip_projection_rows', help: 'Entitlement projection rows by freshness', labelNames: ['state'],-- collect: () => {++ collect: async () => {const t = now();-- const r = db.prepare('SELECT SUM(CASE WHEN valid_until >= ? THEN 1 ELSE 0 END) AS fresh, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t, t);++ const r = await db.prepare('SELECT SUM(CASE WHEN valid_until >= ? THEN 1 ELSE 0 END) AS fresh, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t, t);return [{ labels: { state: 'fresh' }, value: r.fresh || 0 }, { labels: { state: 'stale' }, value: r.stale || 0 }];},});@@@ -86,7 +88,9 @@ function createApp(opts = {}) {app.use(cookieParser());app.get('/api/health', (req, res) => res.json({ ok: true, service: 'vip', version: VERSION, release: release.release, events: outbox.status() }));-- const readiness = createVipReadiness({ db, keys, config, outbox, now, release: release.release, fetchImpl });++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ const readiness = createVipReadiness({ db, keys, config, outbox, now, release: release.release, fetchImpl, valkey });app.get('/api/ready', readiness.handler);// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.release.mount(app, { registry: metrics.registry });@@@ -95,7 +99,7 @@ function createApp(opts = {}) {app.use('/internal', consumer.router);// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.// opts.limitsNow: the limiter's clock (tests).-- const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log });++ const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }@@@ -69,7 +69,7 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient} catch (e) {throw new BillingCallError(`Billing unreachable: ${e.message}`);}-- if (res.status === 401 && !retried) { tokensFor(cap).invalidate(); return call(cap, method, path, { body, key, traceparent, retried: true }); }++ if (res.status === 401 && !retried) { tokensFor(cap).invalidate(); return await call(cap, method, path, { body, key, traceparent, retried: true }); }const data = await res.json().catch(() => null);if (!res.ok) {const code = (data && (data.code || data.error)) || `http_${res.status}`;@@@ -82,21 +82,21 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClientreturn {/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */-- createIntent: ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => call(CAPS.intent, 'POST', '/api/v1/intents', {++ createIntent: async ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => await call(CAPS.intent, 'POST', '/api/v1/intents', {body: { provider, kind: 'subscription', subject: user(subject), streamer: user(creator), auto_renew: !!autoRenew, success_url: successUrl, cancel_url: cancelUrl },key, traceparent,}),/** { subscription, entitlement, transaction } — one period paid from the member's credit. */-- subscribeWithCredit: ({ subscriber, creator, autoRenew, key, traceparent }) => call(CAPS.subscription, 'POST', '/api/v1/subscriptions', {++ subscribeWithCredit: async ({ subscriber, creator, autoRenew, key, traceparent }) => await call(CAPS.subscription, 'POST', '/api/v1/subscriptions', {body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false },key, traceparent,}),-- cancelSubscription: ({ id, key, traceparent }) => call(CAPS.subscription, 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),-- getSubscription: (id) => call(CAPS.entitlement, 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),-- listSubscriptions: ({ streamer, subscriber, status } = {}) => call(CAPS.entitlement, 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),++ cancelSubscription: async ({ id, key, traceparent }) => await call(CAPS.subscription, 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),++ getSubscription: async (id) => await call(CAPS.entitlement, 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),++ listSubscriptions: async ({ streamer, subscriber, status } = {}) => await call(CAPS.entitlement, 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),/** { active, expires_at, subscription } — the authoritative answer. */-- entitlement: (subject, creator) => call(CAPS.entitlement, 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),-- rates: () => call(CAPS.entitlement, 'GET', '/api/v1/rates'),++ entitlement: async (subject, creator) => await call(CAPS.entitlement, 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),++ rates: async () => await call(CAPS.entitlement, 'GET', '/api/v1/rates'),baseUrl: () => base,};}@@@ -28,6 +28,10 @@ function loadConfig(env = process.env) {host: env.HOST || '127.0.0.1',baseUrl,trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:vip:' },++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.dbPath: env.VIP_DB_PATH || './data/vip.db',// Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.@@@ -1,7 +1,7 @@'use strict';/**-- * VIP's own SQLite database (ADR-007: one database per service), WAL, created on boot, idempotent.++ * VIP's own PostgreSQL database (ADR-007: one database per service; ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.** Authority tables (the nine the charter names; VIP is their only writer):* vip_creators a creator (or the network) that offers memberships@@@ -25,246 +25,29 @@*/const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');++const { createDb } = require('openvibe-sdk/db');--const SCHEMA = `--CREATE TABLE IF NOT EXISTS settings (-- id INTEGER PRIMARY KEY CHECK (id = 1),-- created_at TEXT NOT NULL--);----CREATE TABLE IF NOT EXISTS vip_creators (-- id TEXT PRIMARY KEY,-- kind TEXT NOT NULL CHECK (kind IN ('creator', 'network')),-- subject TEXT UNIQUE,-- username TEXT,-- username_lc TEXT UNIQUE,-- display_name TEXT,-- bio TEXT,-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),-- origin TEXT NOT NULL DEFAULT 'self' CHECK (origin IN ('self', 'service', 'import', 'staff', 'system')),-- created_at TEXT NOT NULL,-- updated_at TEXT NOT NULL,-- CHECK ((kind = 'network' AND subject IS NULL) OR (kind = 'creator' AND subject IS NOT NULL))--);----CREATE TABLE IF NOT EXISTS vip_plans (-- id TEXT PRIMARY KEY,-- creator_id TEXT NOT NULL REFERENCES vip_creators(id),-- slug TEXT NOT NULL,-- status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),-- billing_kind TEXT CHECK (billing_kind IS NULL OR billing_kind IN ('channel_subscription')),-- current_version_id TEXT,-- latest_version INTEGER NOT NULL DEFAULT 0,-- sort INTEGER NOT NULL DEFAULT 0,-- created_by TEXT,-- created_at TEXT NOT NULL,-- updated_at TEXT NOT NULL,-- archived_at TEXT,-- UNIQUE (creator_id, slug)--);--CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_plans_one_billing_product-- ON vip_plans (creator_id, billing_kind) WHERE status = 'published' AND billing_kind IS NOT NULL;----CREATE TABLE IF NOT EXISTS vip_plan_versions (-- id TEXT PRIMARY KEY,-- plan_id TEXT NOT NULL REFERENCES vip_plans(id),-- version INTEGER NOT NULL,-- name TEXT NOT NULL,-- description TEXT,-- benefits TEXT NOT NULL DEFAULT '[]',-- terms TEXT NOT NULL,-- change_note TEXT,-- created_by TEXT,-- created_at TEXT NOT NULL,-- published_at TEXT,-- UNIQUE (plan_id, version)--);--CREATE TRIGGER IF NOT EXISTS vip_plan_versions_no_rewrite BEFORE UPDATE ON vip_plan_versions--WHEN NEW.id IS NOT OLD.id OR NEW.plan_id IS NOT OLD.plan_id OR NEW.version IS NOT OLD.version-- OR NEW.name IS NOT OLD.name OR NEW.description IS NOT OLD.description OR NEW.benefits IS NOT OLD.benefits-- OR NEW.terms IS NOT OLD.terms OR NEW.change_note IS NOT OLD.change_note OR NEW.created_by IS NOT OLD.created_by-- OR NEW.created_at IS NOT OLD.created_at OR (OLD.published_at IS NOT NULL AND NEW.published_at IS NOT OLD.published_at)--BEGIN SELECT RAISE(ABORT, 'vip_plan_versions are immutable: edit a plan by creating a new version'); END;--CREATE TRIGGER IF NOT EXISTS vip_plan_versions_no_delete BEFORE DELETE ON vip_plan_versions--BEGIN SELECT RAISE(ABORT, 'vip_plan_versions are immutable: edit a plan by creating a new version'); END;----CREATE TABLE IF NOT EXISTS vip_perks (-- id TEXT PRIMARY KEY,-- creator_id TEXT NOT NULL REFERENCES vip_creators(id),-- key TEXT NOT NULL,-- name TEXT NOT NULL,-- description TEXT,-- kind TEXT NOT NULL DEFAULT 'other' CHECK (kind IN ('badge', 'emote', 'gated_content', 'room', 'role', 'other')),-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),-- created_by TEXT,-- created_at TEXT NOT NULL,-- updated_at TEXT NOT NULL,-- UNIQUE (creator_id, key)--);----CREATE TABLE IF NOT EXISTS vip_plan_perks (-- plan_version_id TEXT NOT NULL REFERENCES vip_plan_versions(id),-- perk_id TEXT NOT NULL REFERENCES vip_perks(id),-- perk_key TEXT NOT NULL,-- perk_name TEXT NOT NULL,-- position INTEGER NOT NULL DEFAULT 0,-- PRIMARY KEY (plan_version_id, perk_id)--);--CREATE INDEX IF NOT EXISTS idx_vip_plan_perks_key ON vip_plan_perks (plan_version_id, perk_key);--CREATE TRIGGER IF NOT EXISTS vip_plan_perks_no_update BEFORE UPDATE ON vip_plan_perks--BEGIN SELECT RAISE(ABORT, 'vip_plan_perks belong to an immutable plan version'); END;--CREATE TRIGGER IF NOT EXISTS vip_plan_perks_no_delete BEFORE DELETE ON vip_plan_perks--BEGIN SELECT RAISE(ABORT, 'vip_plan_perks belong to an immutable plan version'); END;----CREATE TABLE IF NOT EXISTS vip_product_bindings (-- id TEXT PRIMARY KEY,-- perk_id TEXT NOT NULL REFERENCES vip_perks(id),-- product TEXT NOT NULL,-- binding TEXT NOT NULL,-- config TEXT NOT NULL DEFAULT '{}',-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')),-- created_at TEXT NOT NULL,-- updated_at TEXT NOT NULL,-- UNIQUE (perk_id, product, binding)--);----CREATE TABLE IF NOT EXISTS vip_member_preferences (-- member_subject TEXT NOT NULL,-- creator_id TEXT NOT NULL REFERENCES vip_creators(id),-- show_badge INTEGER NOT NULL DEFAULT 1,-- listed INTEGER NOT NULL DEFAULT 0,-- updated_at TEXT NOT NULL,-- PRIMARY KEY (member_subject, creator_id)--);----CREATE TABLE IF NOT EXISTS vip_gated_resource_rules (-- id TEXT PRIMARY KEY,-- creator_id TEXT NOT NULL REFERENCES vip_creators(id),-- resource_service TEXT NOT NULL,-- resource_type TEXT NOT NULL,-- resource_id TEXT NOT NULL,-- requirement TEXT NOT NULL CHECK (requirement IN ('member', 'plan', 'perk')),-- plan_id TEXT REFERENCES vip_plans(id),-- perk_key TEXT,-- sensitive INTEGER NOT NULL DEFAULT 0,-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),-- created_by TEXT,-- created_at TEXT NOT NULL,-- updated_at TEXT NOT NULL,-- CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),-- CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)--);--DROP INDEX IF EXISTS idx_vip_rules_resource;--CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource_creator-- ON vip_gated_resource_rules (resource_service, resource_type, resource_id, creator_id) WHERE status = 'active';----CREATE TABLE IF NOT EXISTS vip_migration_maps (-- source TEXT NOT NULL,-- source_table TEXT NOT NULL,-- source_id TEXT NOT NULL,-- target_type TEXT,-- target_id TEXT,-- status TEXT NOT NULL CHECK (status IN ('imported', 'linked', 'held', 'excluded')),-- reason TEXT,-- run_id TEXT,-- created_at TEXT NOT NULL,-- updated_at TEXT NOT NULL,-- PRIMARY KEY (source, source_table, source_id)--);----CREATE TABLE IF NOT EXISTS vip_memberships (-- id TEXT PRIMARY KEY,-- member_subject TEXT NOT NULL,-- creator_id TEXT NOT NULL REFERENCES vip_creators(id),-- plan_id TEXT REFERENCES vip_plans(id),-- plan_version_id TEXT REFERENCES vip_plan_versions(id),-- billing_subscription_id TEXT,-- origin TEXT NOT NULL CHECK (origin IN ('checkout', 'billing', 'import')),-- joined_at TEXT NOT NULL,-- terms_since TEXT NOT NULL,-- updated_at TEXT NOT NULL,-- UNIQUE (member_subject, creator_id)--);--CREATE INDEX IF NOT EXISTS idx_vip_memberships_creator ON vip_memberships (creator_id);----CREATE TABLE IF NOT EXISTS vip_entitlement_projection (-- member_subject TEXT NOT NULL,-- creator_subject TEXT NOT NULL,-- kind TEXT NOT NULL,-- active INTEGER NOT NULL,-- expires_at TEXT,-- cancel_at_period_end INTEGER NOT NULL DEFAULT 0,-- subscription_id TEXT,-- subscription_status TEXT,-- source TEXT NOT NULL CHECK (source IN ('event', 'billing_check', 'import')),-- last_reason TEXT,-- billing_event_id TEXT,-- billing_as_of INTEGER NOT NULL,-- synced_at INTEGER NOT NULL,-- valid_until INTEGER NOT NULL,-- last_used_at INTEGER,-- PRIMARY KEY (member_subject, creator_subject, kind)--);--CREATE INDEX IF NOT EXISTS idx_vip_projection_creator ON vip_entitlement_projection (creator_subject, kind, active);----CREATE TABLE IF NOT EXISTS vip_checkouts (-- id TEXT PRIMARY KEY,-- member_subject TEXT NOT NULL,-- creator_id TEXT NOT NULL REFERENCES vip_creators(id),-- plan_id TEXT NOT NULL REFERENCES vip_plans(id),-- plan_version_id TEXT NOT NULL REFERENCES vip_plan_versions(id),-- provider TEXT NOT NULL,-- status TEXT NOT NULL CHECK (status IN ('created', 'handed_off', 'paid', 'failed', 'used')),-- billing_intent_id TEXT,-- billing_subscription_id TEXT,-- checkout_url TEXT,-- checkout_ref TEXT,-- error TEXT,-- created_at TEXT NOT NULL,-- updated_at TEXT NOT NULL--);--CREATE INDEX IF NOT EXISTS idx_vip_checkouts_member ON vip_checkouts (member_subject, creator_id, created_at);--`;----function openDb(dbPath) {-- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });-- const db = new Database(dbPath);-- db.pragma('journal_mode = WAL');-- db.pragma('foreign_keys = ON');-- db.pragma('busy_timeout = 5000');-- migrate(db);-- return db;--}++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');/**-- * Columns added after the first schema, for new and existing databases alike (ALTER TABLE … ADD-- * COLUMN when missing; idempotent, never rewrites a row).++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database++ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle; they also seed++ * the settings row and the network creator.*/--const COLUMNS = [-- // The creator's choice: their member count on the public card and widget (server/domain/cards.js).-- ['vip_creators', 'show_member_count', 'INTEGER NOT NULL DEFAULT 1'],--];----function addColumns(db) {-- const have = new Map();-- for (const [table, name, def] of COLUMNS) {-- if (!have.has(table)) have.set(table, new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));-- if (!have.get(table).has(name)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`); have.get(table).add(name); }++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh vip)');++ log.warn(`[VIP] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);++ fs.mkdirSync(DEV_PGLITE, { recursive: true });++ const db = createDb({ pglite: DEV_PGLITE, service: 'vip', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;}++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'vip-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'vip', registry, log });}--function migrate(db) {-- db.exec(SCHEMA);-- db.transaction(() => addColumns(db))();-- const now = new Date().toISOString();-- db.prepare('INSERT OR IGNORE INTO settings (id, created_at) VALUES (1, ?)').run(now);-- // The network itself offers network-wide plans and owns network perks.-- db.prepare(`INSERT OR IGNORE INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)-- VALUES ('network', 'network', NULL, NULL, NULL, 'OpenVibe', 'system', ?, ?)`).run(now, now);--}----module.exports = { openDb, migrate, COLUMNS };++module.exports = { openDb, MIGRATIONS };@@@ -59,17 +59,19 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = creturn pending;}-- const kindOf = (perkId) => { const p = perks.byId(perkId); return p ? p.kind : 'other'; };++ const kindOf = async (perkId) => { const p = await perks.byId(perkId); return p ? p.kind : 'other'; };/** The published plans of a creator, each at the version new members buy. */-- function publicPlans(c) {-- return plans.list({ creatorId: c.id }).map((p) => plans.present(p)).filter((p) => p.current_version);++ async function publicPlans(c) {++ return (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))).filter((p) => p.current_version);}-- function badge(c, list = publicPlans(c)) {++ async function badge(c, list = null) {++ if (!list) list = await publicPlans(c);const plan = list.find((p) => p.purchasable) || list[0];const v = plan && plan.current_version;-- const perk = v && v.perks.find((x) => kindOf(x.id) === 'badge');++ const kinds = v ? await Promise.all(v.perks.map(async (x) => await kindOf(x.id))) : [];++ const perk = v && v.perks.find((_, i) => kinds[i] === 'badge');const label = perk ? perk.name.replace(BADGE_SUFFIX, '').trim() || perk.name : 'Member';return { label: label.slice(0, 24) };}@@@ -85,28 +87,28 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c/** The public card of a creator row, or null when it has none (no handle, suspended, no published plan). */async function card(c) {if (!c || c.kind !== 'creator' || c.status !== 'active' || !c.username) return null;-- const list = publicPlans(c);++ const list = await publicPlans(c);if (!list.length) return null;const u = urls(c);const joiningOpen = config.billing.providers.length > 0 && list.some((p) => p.purchasable);-- const [members, sub] = await Promise.all([memberCount(c), list.some((p) => p.purchasable) ? price() : null]);++ const [members, sub] = await Promise.all([memberCount(c), list.some((p) => p.purchasable) ? await price() : null]);return {creator: { username: c.username, display_name: c.display_name || c.username, url: u.page },-- plans: list.map((p) => {++ plans: (await Promise.all(list.map(async (p) => {const v = p.current_version;return {id: p.id, slug: p.slug, name: v.name, description: v.description, benefits: v.benefits, version: v.version,-- perks: v.perks.map((x) => ({ key: x.key, name: x.name, kind: kindOf(x.id) })),++ perks: (await Promise.all(v.perks.map(async (x) => ({ key: x.key, name: x.name, kind: await kindOf(x.id) })))),purchasable: p.purchasable,join_url: `${u.page}#plan-${encodeURIComponent(p.slug)}`,terms_url: `${u.page}/plans/${encodeURIComponent(p.slug)}`,};-- }),++ }))),join_url: u.page,joining_open: joiningOpen,price: sub ? { price_cents: sub.price_cents, period_days: sub.period_days, charged_by: 'OpenVibe.Billing' } : null,members: { count: members.count, shown: members.shown, stale: members.stale },-- badge: { ...badge(c, list), svg_url: u.badge },++ badge: { ...await badge(c, list), svg_url: u.badge },widget_url: u.widget,card_url: u.card,};@@@ -29,13 +29,13 @@ function billingProblem(err) {}function createCheckout({ db, now, config, billing, creators, plans, entitlements }) {-- const byId = (id) => db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;++ const byId = async (id) => await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {-- const plan = plans.byId(String(planId || ''));++ const plan = await plans.byId(String(planId || ''));if (!plan || plan.status !== 'published') fail(404, 'vip.plan_not_found', 'no published plan with that id');if (!plan.billing_kind) fail(409, 'vip.checkout.unavailable', 'this plan is not sold through Billing yet');-- const creator = creators.byId(plan.creator_id);++ const creator = await creators.byId(plan.creator_id);if (!creator || creator.status !== 'active' || !creator.subject) fail(409, 'vip.checkout.unavailable', 'this creator is not taking new members');if (creator.subject === member) fail(422, 'vip.checkout.self', 'you cannot join your own plan');const p = String(provider || '').toLowerCase();@@@ -50,27 +50,27 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementconst at = iso(now());const id = prefixedId('vco', now());-- db.prepare(`INSERT INTO vip_checkouts (id, member_subject, creator_id, plan_id, plan_version_id, provider, status, created_at, updated_at)++ await db.prepare(`INSERT INTO vip_checkouts (id, member_subject, creator_id, plan_id, plan_version_id, provider, status, created_at, updated_at)VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?)`).run(id, member, creator.id, plan.id, plan.current_version_id, p, at, at);-- const mark = (fields) => {++ const mark = async (fields) => {const cols = Object.keys(fields);-- db.prepare(`UPDATE vip_checkouts SET ${cols.map((c) => `${c} = @${c}`).join(', ')}, updated_at = @updated_at WHERE id = @id`).run({ ...fields, updated_at: iso(now()), id });++ await db.prepare(`UPDATE vip_checkouts SET ${cols.map((c) => `${c} = @${c}`).join(', ')}, updated_at = @updated_at WHERE id = @id`).run({ ...fields, updated_at: iso(now()), id });};if (p === 'credit') {let out;try {out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, autoRenew, key: `vip:checkout:${id}`, traceparent });-- } catch (e) { mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }++ } catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }const sub = out.subscription || {};const ent = out.entitlement || {};-- mark({ status: 'paid', billing_subscription_id: sub.id || null });-- entitlements.apply({++ await mark({ status: 'paid', billing_subscription_id: sub.id || null });++ await entitlements.apply({member, creator: creator.subject, active: !!ent.active, expiresAt: ent.expires_at || sub.current_period_end || null,cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id || null, subscriptionStatus: sub.status || null,reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, traceparent,});-- return { checkout: present(byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };++ return { checkout: present(await byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };}let out;@@@ -81,7 +81,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementcancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,key: `vip:checkout:${id}`, traceparent,});-- } catch (e) { mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }++ } catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }const intent = out.intent || {};let url = out.checkout_url || null;if (!url && intent.checkout_ref && config.billing.powerchatLinkTemplate) {@@@ -89,13 +89,13 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement}// Only ever send a member to an http(s) page (never javascript:/data: from a bad answer).if (url && !/^https?:\/\//i.test(url)) url = null;-- mark({ status: 'handed_off', billing_intent_id: intent.id || null, checkout_url: url, checkout_ref: intent.checkout_ref || null });-- return { checkout: present(byId(id)), membership_started: false, checkout_url: url, checkout_ref: intent.checkout_ref || null, amount_cents: intent.amount_cents || null };++ await mark({ status: 'handed_off', billing_intent_id: intent.id || null, checkout_url: url, checkout_ref: intent.checkout_ref || null });++ return { checkout: present(await byId(id)), membership_started: false, checkout_url: url, checkout_ref: intent.checkout_ref || null, amount_cents: intent.amount_cents || null };}/** Cancel at period end, through Billing. Only the member themself. */async function cancel({ member, creatorId, traceparent }) {-- const creator = creators.byId(creatorId);++ const creator = await creators.byId(creatorId);if (!creator || !creator.subject) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);let row;try { row = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }@@@ -112,7 +112,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlementout = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent });} catch (e) { billingProblem(e); }const s = (out && out.subscription) || {};-- entitlements.applyCanceled({++ await entitlements.applyCanceled({member, creator: creator.subject, subscriptionId: s.id || sub.id, subscriptionStatus: s.status || 'active',currentPeriodEnd: s.current_period_end || null, asOf: now(), traceparent, source: 'billing_check',});@@@ -11,57 +11,57 @@ const { fail, prefixedId, iso, text } = require('../util');const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;function createCreators({ db, now }) {-- const byId = (id) => db.prepare('SELECT * FROM vip_creators WHERE id = ?').get(id) || null;-- const bySubject = (s) => db.prepare('SELECT * FROM vip_creators WHERE subject = ?').get(s) || null;-- const byUsername = (u) => (u ? db.prepare('SELECT * FROM vip_creators WHERE username_lc = ?').get(String(u).toLowerCase()) || null : null);-- const network = () => byId('network');++ const byId = async (id) => await db.prepare('SELECT * FROM vip_creators WHERE id = ?').get(id) || null;++ const bySubject = async (s) => await db.prepare('SELECT * FROM vip_creators WHERE subject = ?').get(s) || null;++ const byUsername = async (u) => (u ? await db.prepare('SELECT * FROM vip_creators WHERE username_lc = ?').get(String(u).toLowerCase()) || null : null);++ const network = async () => await byId('network');/*** The creator row for a subject, created on first use. username/displayName (from the creator's* own Network token, the importer or the identity service) refresh the cached handle; a handle* another row still holds (a rename) is taken over, the stale row keeps no handle.*/-- function ensure({ subject, username = null, displayName = null, origin = 'self' }) {++ async function ensure({ subject, username = null, displayName = null, origin = 'self' }) {if (!subject) fail(422, 'vip.invalid_subject', 'a creator needs a subject');const at = iso(now());const handle = username && USERNAME_RE.test(String(username)) ? String(username) : null;-- let row = bySubject(subject);++ let row = await bySubject(subject);if (handle) {-- const holder = byUsername(handle);-- if (holder && holder.subject !== subject) db.prepare('UPDATE vip_creators SET username = NULL, username_lc = NULL, updated_at = ? WHERE id = ?').run(at, holder.id);++ const holder = await byUsername(handle);++ if (holder && holder.subject !== subject) await db.prepare('UPDATE vip_creators SET username = NULL, username_lc = NULL, updated_at = ? WHERE id = ?').run(at, holder.id);}if (!row) {const id = prefixedId('vcr', now());-- db.prepare(`INSERT INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)++ await db.prepare(`INSERT INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)VALUES (?, 'creator', ?, ?, ?, ?, ?, ?, ?)`).run(id, subject, handle, handle ? handle.toLowerCase() : null, displayName ? String(displayName).slice(0, 80) : null, origin, at, at);-- return byId(id);++ return await byId(id);}const changes = {};if (handle && handle !== row.username) changes.username = handle;if (displayName && String(displayName).slice(0, 80) !== row.display_name) changes.display_name = String(displayName).slice(0, 80);if (Object.keys(changes).length) {-- db.prepare(`UPDATE vip_creators SET username = COALESCE(?, username), username_lc = COALESCE(?, username_lc), display_name = COALESCE(?, display_name), updated_at = ? WHERE id = ?`)++ await db.prepare(`UPDATE vip_creators SET username = COALESCE(?, username), username_lc = COALESCE(?, username_lc), display_name = COALESCE(?, display_name), updated_at = ? WHERE id = ?`).run(changes.username || null, changes.username ? changes.username.toLowerCase() : null, changes.display_name || null, at, row.id);-- row = byId(row.id);++ row = await byId(row.id);}return row;}-- function update(id, { displayName, bio, showMemberCount }) {-- const row = byId(id);++ async function update(id, { displayName, bio, showMemberCount }) {++ const row = await byId(id);if (!row) fail(404, 'vip.creator_not_found', `no creator ${id}`);const name = displayName !== undefined ? text(displayName, 'display_name', 80) : row.display_name;const about = bio !== undefined ? text(bio, 'bio', 2000) : row.bio;const count = showMemberCount !== undefined ? (showMemberCount ? 1 : 0) : row.show_member_count;-- db.prepare('UPDATE vip_creators SET display_name = ?, bio = ?, show_member_count = ?, updated_at = ? WHERE id = ?').run(name, about, count, iso(now()), id);-- return byId(id);++ await db.prepare('UPDATE vip_creators SET display_name = ?, bio = ?, show_member_count = ?, updated_at = ? WHERE id = ?').run(name, about, count, iso(now()), id);++ return await byId(id);}/** Creators with at least one published plan, for the directory and the sitemap. */-- function listPublic({ limit = 200 } = {}) {-- return db.prepare(`SELECT c.* FROM vip_creators c WHERE c.status = 'active' AND c.kind = 'creator' AND c.username IS NOT NULL++ async function listPublic({ limit = 200 } = {}) {++ return await db.prepare(`SELECT c.* FROM vip_creators c WHERE c.status = 'active' AND c.kind = 'creator' AND c.username IS NOT NULLAND EXISTS (SELECT 1 FROM vip_plans p WHERE p.creator_id = c.id AND p.status = 'published')-- ORDER BY COALESCE(c.display_name, c.username) COLLATE NOCASE LIMIT ?`).all(limit);++ ORDER BY lower(COALESCE(c.display_name, c.username)) LIMIT ?`).all(limit);}function present(c) {@@@ -24,7 +24,7 @@ const KIND = 'channel_subscription';function createEntitlements({ db, now, config, billing, outbox, memberships, plans, log = console }) {const { maxAgeMs, graceMs } = config.projection;-- const getRow = (member, creator, kind = KIND) => db.prepare('SELECT * FROM vip_entitlement_projection WHERE member_subject = ? AND creator_subject = ? AND kind = ?').get(member, creator, kind) || null;++ const getRow = async (member, creator, kind = KIND) => await db.prepare('SELECT * FROM vip_entitlement_projection WHERE member_subject = ? AND creator_subject = ? AND kind = ?').get(member, creator, kind) || null;function validUntil(at, active, expiresAt) {const cap = at + maxAgeMs;@@@ -33,8 +33,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, plareturn Number.isFinite(end) ? Math.min(cap, end) : at;}-- function emitChanged(row, prev, { reason, membership }, traceparent) {-- const v = membership && membership.plan_version_id ? plans.version(membership.plan_version_id) : null;++ async function emitChanged(row, prev, { reason, membership }, traceparent) {++ const v = membership && membership.plan_version_id ? await plans.version(membership.plan_version_id) : null;outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,@@@ -52,14 +52,14 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla* subscriptionStatus?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),* origin?, checkoutId?, traceparent? }*/-- function apply(input) {-- return db.transaction(() => {++ async function apply(input) {++ return await db.tx(async () => {const kind = input.kind || KIND;-- const prev = getRow(input.member, input.creator, kind);++ const prev = await getRow(input.member, input.creator, kind);const at = now();if (prev && input.asOf < prev.billing_as_of) {if (!input.active && prev.active) {-- db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(input.member, input.creator, kind);++ await db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(input.member, input.creator, kind);return { outcome: 'doubt', changed: false };}return { outcome: 'stale_ignored', changed: false };@@@ -74,7 +74,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, plabilling_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, input.active, input.expiresAt),last_used_at: prev ? prev.last_used_at : null,};-- db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,++ await db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,subscription_status, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status,@source, @last_reason, @billing_event_id, @billing_as_of, @synced_at, @valid_until, @last_used_at)@@@ -83,43 +83,43 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, plasubscription_status = excluded.subscription_status, source = excluded.source, last_reason = excluded.last_reason,billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,valid_until = excluded.valid_until`).run(next);-- const { membership } = memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });++ const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });// A first look that finds no membership is not a change anyone needs to hear about.const changed = prev? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end): !!next.active;-- if (changed) emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);-- return { outcome: changed ? 'changed' : 'unchanged', changed, row: getRow(input.member, input.creator, kind), membership };-- })();++ if (changed) await emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);++ return { outcome: changed ? 'changed' : 'unchanged', changed, row: await getRow(input.member, input.creator, kind), membership };++ });}/** A cancellation scheduled at Billing: the period still runs; only the flag changes. */-- function applyCanceled({ member, creator, subscriptionId, subscriptionStatus, currentPeriodEnd, eventId, asOf, traceparent, source = 'event' }) {-- return db.transaction(() => {-- const prev = getRow(member, creator);++ async function applyCanceled({ member, creator, subscriptionId, subscriptionStatus, currentPeriodEnd, eventId, asOf, traceparent, source = 'event' }) {++ return await db.tx(async () => {++ const prev = await getRow(member, creator);if (!prev) return { outcome: 'no_projection', changed: false };if (asOf < prev.billing_as_of) return { outcome: 'stale_ignored', changed: false };const active = subscriptionStatus === 'active' ? !!prev.active : false;-- return apply({++ return await apply({member, creator, active, expiresAt: active ? (prev.expires_at || currentPeriodEnd) : prev.expires_at, cancelAtPeriodEnd: true,subscriptionId, subscriptionStatus, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,});-- })();++ });}/** Something happened that Billing will describe in detail (a refund): stop trusting the row. */-- function doubt(member, creator, kind = KIND) {-- return db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(member, creator, kind).changes > 0;++ async function doubt(member, creator, kind = KIND) {++ return (await db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(member, creator, kind)).changes > 0;}-- function answer(row, source, { stale = false, membership } = {}) {++ async function answer(row, source, { stale = false, membership } = {}) {const t = now();const active = !!row.active && !!row.expires_at && Date.parse(row.expires_at) > t;return {member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,status: active ? 'active' : 'inactive', active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,source, stale, valid_until: iso(Math.max(0, row.valid_until)), checked_at: iso(t),-- membership: membership ? memberships.present(membership) : null,++ membership: membership ? await memberships.present(membership) : null,};}function unknown(member, creator, reason) {@@@ -129,8 +129,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla};}-- const membershipOf = (member, creatorSubject) => {-- const row = db.prepare('SELECT m.* FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?').get(member, creatorSubject);++ const membershipOf = async (member, creatorSubject) => {++ const row = await db.prepare('SELECT m.* FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?').get(member, creatorSubject);return row || null;};@@@ -138,12 +138,12 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, plaasync function authoritative(member, creator, { traceparent } = {}) {const e = await billing.entitlement(member, creator);const sub = e && e.subscription;-- const out = apply({++ const out = await apply({member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,subscriptionStatus: sub ? sub.status : undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,});-- return out.row || getRow(member, creator);++ return out.row || await getRow(member, creator);}/**@@@ -153,22 +153,22 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, plaasync function check(member, creator, { mode = 'auto', traceparent } = {}) {if (member === creator) return { ...unknown(member, creator, 'self'), status: 'inactive', reason: 'a creator is not a member of their own plans' };const t = now();-- let row = getRow(member, creator);-- if (row) db.prepare('UPDATE vip_entitlement_projection SET last_used_at = ? WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(t, member, creator, KIND);++ let row = await getRow(member, creator);++ if (row) await db.prepare('UPDATE vip_entitlement_projection SET last_used_at = ? WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(t, member, creator, KIND);const fresh = row && t <= row.valid_until;const withinGrace = row && t <= row.valid_until + graceMs;if (mode === 'projection') {-- if (fresh) return answer(row, 'projection', { membership: membershipOf(member, creator) });-- if (withinGrace) return answer(row, 'projection', { stale: true, membership: membershipOf(member, creator) });++ if (fresh) return await answer(row, 'projection', { membership: await membershipOf(member, creator) });++ if (withinGrace) return await answer(row, 'projection', { stale: true, membership: await membershipOf(member, creator) });return unknown(member, creator, row ? 'projection_expired' : 'no_projection');}-- if (mode === 'auto' && fresh) return answer(row, 'projection', { membership: membershipOf(member, creator) });++ if (mode === 'auto' && fresh) return await answer(row, 'projection', { membership: await membershipOf(member, creator) });try {row = await authoritative(member, creator, { traceparent });-- return answer(row, 'billing', { membership: membershipOf(member, creator) });++ return await answer(row, 'billing', { membership: await membershipOf(member, creator) });} catch (err) {log.warn(`[VIP] authoritative entitlement check failed (${member} → ${creator}): ${err.message}`);-- if (mode === 'auto' && withinGrace) return { ...answer(row, 'projection', { stale: true, membership: membershipOf(member, creator) }), reason: 'billing_unavailable' };++ if (mode === 'auto' && withinGrace) return { ...await answer(row, 'projection', { stale: true, membership: await membershipOf(member, creator) }), reason: 'billing_unavailable' };return unknown(member, creator, 'billing_unavailable');}}@@@ -179,7 +179,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla*/async function refreshDue({ limit = 50 } = {}) {const t = now();-- const rows = db.prepare(`SELECT member_subject, creator_subject FROM vip_entitlement_projection++ const rows = await db.prepare(`SELECT member_subject, creator_subject FROM vip_entitlement_projectionWHERE kind = ? AND last_used_at >= ? AND valid_until <= ? ORDER BY valid_until LIMIT ?`).all(KIND, t - 3600_000, t + 120_000, limit);let ok = 0; let failed = 0;for (const r of rows) {@@@ -189,9 +189,9 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla}/** Members of a creator according to the projection (fresh rows only; never past valid_until). */-- function projectedMembers(creator) {++ async function projectedMembers(creator) {const t = now();-- return db.prepare(`SELECT * FROM vip_entitlement_projection WHERE creator_subject = ? AND kind = ? AND active = 1 AND valid_until >= ? AND expires_at > ?++ return await db.prepare(`SELECT * FROM vip_entitlement_projection WHERE creator_subject = ? AND kind = ? AND active = 1 AND valid_until >= ? AND expires_at > ?ORDER BY expires_at DESC`).all(creator, KIND, t, iso(t));}@@@ -19,7 +19,7 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), logconst policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements });const cards = createCards({ db, config, creators, plans, perks, billing, now, log });-- const tx = (fn) => db.transaction(fn)();++ const tx = async (fn) => await db.tx(fn);return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout, cards };}@@@ -17,31 +17,31 @@ const { prefixedId, iso, json } = require('../util');const CHECKOUT_WINDOW_MS = 14 * 86_400_000;function createMemberships({ db, now, creators, plans }) {-- const get = (member, creatorId) => db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? AND creator_id = ?').get(member, creatorId) || null;-- const byId = (id) => db.prepare('SELECT * FROM vip_memberships WHERE id = ?').get(id) || null;++ const get = async (member, creatorId) => await db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? AND creator_id = ?').get(member, creatorId) || null;++ const byId = async (id) => await db.prepare('SELECT * FROM vip_memberships WHERE id = ?').get(id) || null;-- function pendingCheckout(member, creatorId) {++ async function pendingCheckout(member, creatorId) {const since = iso(now() - CHECKOUT_WINDOW_MS);-- return db.prepare(`SELECT * FROM vip_checkouts WHERE member_subject = ? AND creator_id = ? AND status IN ('handed_off', 'paid')++ return await db.prepare(`SELECT * FROM vip_checkouts WHERE member_subject = ? AND creator_id = ? AND status IN ('handed_off', 'paid')AND created_at >= ? ORDER BY created_at DESC LIMIT 1`).get(member, creatorId, since) || null;}/** Record the version a membership is under. Inside the projection's transaction. */-- function onProjection({ prev, next, reason, origin = null, checkoutId = null }) {-- const creator = creators.bySubject(next.creator_subject) || creators.ensure({ subject: next.creator_subject, origin: 'system' });++ async function onProjection({ prev, next, reason, origin = null, checkoutId = null }) {++ const creator = await creators.bySubject(next.creator_subject) || await creators.ensure({ subject: next.creator_subject, origin: 'system' });const member = next.member_subject;-- let m = get(member, creator.id);++ let m = await get(member, creator.id);const at = iso(now());if (!next.active) return { membership: m, creator };// A membership that exists keeps its version unless this is a NEW purchase after a lapse:// Billing says 'granted' after we saw it inactive, or the member's own checkout settled.-- const pending = checkoutId ? null : pendingCheckout(member, creator.id);++ const pending = checkoutId ? null : await pendingCheckout(member, creator.id);const lapsed = prev && !prev.active;const newPurchase = !!checkoutId || (lapsed && (reason === 'granted' || (reason !== 'renewed' && !!pending)));if (m && !newPurchase) {if (next.subscription_id && next.subscription_id !== m.billing_subscription_id) {-- db.prepare('UPDATE vip_memberships SET billing_subscription_id = ?, updated_at = ? WHERE id = ?').run(next.subscription_id, at, m.id);-- m = get(member, creator.id);++ await db.prepare('UPDATE vip_memberships SET billing_subscription_id = ?, updated_at = ? WHERE id = ?').run(next.subscription_id, at, m.id);++ m = await get(member, creator.id);}return { membership: m, creator };}@@@ -49,54 +49,54 @@ function createMemberships({ db, now, creators, plans }) {let planId = null;let versionId = null;let how = origin || 'billing';-- const checkout = checkoutId ? db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(checkoutId) : pending;++ const checkout = checkoutId ? await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(checkoutId) : pending;if (checkout) {planId = checkout.plan_id; versionId = checkout.plan_version_id; how = 'checkout';-- db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);++ await db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);} else {-- const plan = plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind);++ const plan = await plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind);if (plan) { planId = plan.id; versionId = plan.current_version_id; }}if (m) {-- db.prepare(`UPDATE vip_memberships SET plan_id = ?, plan_version_id = ?, billing_subscription_id = COALESCE(?, billing_subscription_id), origin = ?,++ await db.prepare(`UPDATE vip_memberships SET plan_id = ?, plan_version_id = ?, billing_subscription_id = COALESCE(?, billing_subscription_id), origin = ?,terms_since = ?, updated_at = ? WHERE id = ?`).run(planId, versionId, next.subscription_id || null, how, at, at, m.id);} else {-- db.prepare(`INSERT INTO vip_memberships (id, member_subject, creator_id, plan_id, plan_version_id, billing_subscription_id, origin, joined_at, terms_since, updated_at)++ await db.prepare(`INSERT INTO vip_memberships (id, member_subject, creator_id, plan_id, plan_version_id, billing_subscription_id, origin, joined_at, terms_since, updated_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prefixedId('vms', now()), member, creator.id, planId, versionId, next.subscription_id || null, how, at, at, at);}-- return { membership: get(member, creator.id), creator };++ return { membership: await get(member, creator.id), creator };}-- function preferences(member, creatorId) {-- const p = db.prepare('SELECT * FROM vip_member_preferences WHERE member_subject = ? AND creator_id = ?').get(member, creatorId);++ async function preferences(member, creatorId) {++ const p = await db.prepare('SELECT * FROM vip_member_preferences WHERE member_subject = ? AND creator_id = ?').get(member, creatorId);return { show_badge: p ? !!p.show_badge : true, listed: p ? !!p.listed : false };}-- function setPreferences(member, creatorId, { showBadge, listed }) {-- const cur = preferences(member, creatorId);++ async function setPreferences(member, creatorId, { showBadge, listed }) {++ const cur = await preferences(member, creatorId);const sb = showBadge === undefined ? cur.show_badge : !!showBadge;const li = listed === undefined ? cur.listed : !!listed;-- db.prepare(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES (?, ?, ?, ?, ?)++ await db.prepare(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES (?, ?, ?, ?, ?)ON CONFLICT (member_subject, creator_id) DO UPDATE SET show_badge = excluded.show_badge, listed = excluded.listed, updated_at = excluded.updated_at`).run(member, creatorId, sb ? 1 : 0, li ? 1 : 0, iso(now()));-- return preferences(member, creatorId);++ return await preferences(member, creatorId);}-- const forMember = (member) => db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? ORDER BY joined_at DESC').all(member);-- const forCreator = (creatorId) => db.prepare('SELECT * FROM vip_memberships WHERE creator_id = ? ORDER BY joined_at DESC').all(creatorId);++ const forMember = async (member) => await db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? ORDER BY joined_at DESC').all(member);++ const forCreator = async (creatorId) => await db.prepare('SELECT * FROM vip_memberships WHERE creator_id = ? ORDER BY joined_at DESC').all(creatorId);/** The perk keys a membership's version grants (its own snapshot, never the plan's latest). */-- function perkKeys(m) {++ async function perkKeys(m) {if (!m || !m.plan_version_id) return [];-- return plans.versionPerks(m.plan_version_id).map((p) => p.perk_key);++ return (await plans.versionPerks(m.plan_version_id)).map((p) => p.perk_key);}-- function present(m) {++ async function present(m) {if (!m) return null;-- const v = m.plan_version_id ? plans.version(m.plan_version_id) : null;++ const v = m.plan_version_id ? await plans.version(m.plan_version_id) : null;return {id: m.id, member: { type: 'user', id: m.member_subject }, creator_id: m.creator_id,plan_id: m.plan_id, plan_version: v ? { id: v.id, version: v.version, name: v.name, terms: json(v.terms, {}) } : null,-- perks: perkKeys(m), origin: m.origin, joined_at: m.joined_at, terms_since: m.terms_since,++ perks: await perkKeys(m), origin: m.origin, joined_at: m.joined_at, terms_since: m.terms_since,};}@@@ -15,8 +15,8 @@ const PRODUCT_RE = /^[a-z][a-z0-9-]{1,39}$/;const BINDING_RE = /^[a-z][a-z0-9_.]{1,63}$/;function createPerks({ db, now }) {-- const byId = (id) => db.prepare('SELECT * FROM vip_perks WHERE id = ?').get(id) || null;-- const byKey = (creatorId, key) => db.prepare('SELECT * FROM vip_perks WHERE creator_id = ? AND key = ?').get(creatorId, key) || null;++ const byId = async (id) => await db.prepare('SELECT * FROM vip_perks WHERE id = ?').get(id) || null;++ const byKey = async (creatorId, key) => await db.prepare('SELECT * FROM vip_perks WHERE creator_id = ? AND key = ?').get(creatorId, key) || null;function cleanBindings(list) {if (list == null) return null;@@@ -34,65 +34,65 @@ function createPerks({ db, now }) {});}-- function setBindings(perkId, list) {++ async function setBindings(perkId, list) {const at = iso(now());const keep = new Set(list.map((b) => `${b.product}:${b.binding}`));-- for (const b of db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active'").all(perkId)) {-- if (!keep.has(`${b.product}:${b.binding}`)) db.prepare("UPDATE vip_product_bindings SET status = 'removed', updated_at = ? WHERE id = ?").run(at, b.id);++ for (const b of await db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active'").all(perkId)) {++ if (!keep.has(`${b.product}:${b.binding}`)) await db.prepare("UPDATE vip_product_bindings SET status = 'removed', updated_at = ? WHERE id = ?").run(at, b.id);}const up = db.prepare(`INSERT INTO vip_product_bindings (id, perk_id, product, binding, config, status, created_at, updated_at)VALUES (?, ?, ?, ?, ?, 'active', ?, ?)ON CONFLICT (perk_id, product, binding) DO UPDATE SET config = excluded.config, status = 'active', updated_at = excluded.updated_at`);-- for (const b of list) up.run(prefixedId('vpb', now()), perkId, b.product, b.binding, JSON.stringify(b.config), at, at);++ for (const b of list) await up.run(prefixedId('vpb', now()), perkId, b.product, b.binding, JSON.stringify(b.config), at, at);}-- function create({ creatorId, key, name, description, kind = 'other', bindings = [], actor = null }) {++ async function create({ creatorId, key, name, description, kind = 'other', bindings = [], actor = null }) {const k = slug(key || name, 'key');-- if (byKey(creatorId, k)) fail(409, 'vip.perk_exists', `a perk with key ${k} already exists`);++ if (await byKey(creatorId, k)) fail(409, 'vip.perk_exists', `a perk with key ${k} already exists`);if (!KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);const clean = cleanBindings(bindings) || [];const at = iso(now());const id = prefixedId('vpk', now());-- db.transaction(() => {-- db.prepare(`INSERT INTO vip_perks (id, creator_id, key, name, description, kind, status, created_by, created_at, updated_at)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO vip_perks (id, creator_id, key, name, description, kind, status, created_by, created_at, updated_at)VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, k, text(name, 'name', 80, { required: true }), text(description, 'description', 500), kind, actor, at, at);-- setBindings(id, clean);-- })();-- return byId(id);++ await setBindings(id, clean);++ });++ return await byId(id);}-- function update(perkId, { name, description, kind, status, bindings }) {-- const p = byId(perkId);++ async function update(perkId, { name, description, kind, status, bindings }) {++ const p = await byId(perkId);if (!p) fail(404, 'vip.perk_not_found', `no perk ${perkId}`);if (kind !== undefined && !KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);if (status !== undefined && !['active', 'retired'].includes(status)) fail(422, 'vip.invalid_input', 'status must be active or retired');const clean = cleanBindings(bindings);-- db.transaction(() => {-- db.prepare('UPDATE vip_perks SET name = ?, description = ?, kind = ?, status = ?, updated_at = ? WHERE id = ?').run(++ await db.tx(async () => {++ await db.prepare('UPDATE vip_perks SET name = ?, description = ?, kind = ?, status = ?, updated_at = ? WHERE id = ?').run(name !== undefined ? text(name, 'name', 80, { required: true }) : p.name,description !== undefined ? text(description, 'description', 500) : p.description,kind !== undefined ? kind : p.kind, status !== undefined ? status : p.status, iso(now()), perkId);-- if (clean) setBindings(perkId, clean);-- })();-- return byId(perkId);++ if (clean) await setBindings(perkId, clean);++ });++ return await byId(perkId);}/** Perks usable in a creator's plans: their own plus the network's. */-- function list({ creatorId, includeNetwork = true, includeRetired = false }) {++ async function list({ creatorId, includeNetwork = true, includeRetired = false }) {const owners = includeNetwork && creatorId !== 'network' ? [creatorId, 'network'] : [creatorId];-- return db.prepare(`SELECT * FROM vip_perks WHERE creator_id IN (${owners.map(() => '?').join(',')}) ${includeRetired ? '' : "AND status = 'active'"}-- ORDER BY creator_id = 'network', name COLLATE NOCASE`).all(...owners);++ return await db.prepare(`SELECT * FROM vip_perks WHERE creator_id IN (${owners.map(() => '?').join(',')}) ${includeRetired ? '' : "AND status = 'active'"}++ ORDER BY creator_id = 'network', lower(name)`).all(...owners);}/** Resolve perk ids or keys for a plan of creatorId: own or network perks, active only. */-- function resolveForPlan(creatorId, refs) {++ async function resolveForPlan(creatorId, refs) {if (refs == null) return null;if (!Array.isArray(refs) || refs.length > 30) fail(422, 'vip.invalid_input', 'perks must be a list of at most 30 perk ids or keys');const out = [];const seen = new Set();for (const r of refs) {const s = String(r || '');-- let p = s.startsWith('vpk_') ? byId(s) : (byKey(creatorId, s) || byKey('network', s));++ let p = s.startsWith('vpk_') ? await byId(s) : (await byKey(creatorId, s) || await byKey('network', s));if (p && p.creator_id !== creatorId && p.creator_id !== 'network') p = null;if (!p || p.status !== 'active') fail(422, 'vip.perk_not_found', `no active perk ${s} for this creator`);if (!seen.has(p.id)) { seen.add(p.id); out.push(p); }@@@ -100,7 +100,7 @@ function createPerks({ db, now }) {return out;}-- const bindingsOf = (perkId) => db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);++ const bindingsOf = async (perkId) => await db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);/*** The perks a presented membership's plan version grants, with their active bindings for one@@@ -108,24 +108,24 @@ function createPerks({ db, now }) {* binding are left out). The version's snapshot names the keys; a key resolves to the creator's* own perk first, then the network's.*/-- function forMembership(membership, product) {++ async function forMembership(membership, product) {if (!membership || !Array.isArray(membership.perks) || !membership.perks.length) return [];const out = [];for (const key of membership.perks) {-- const p = byKey(membership.creator_id, key) || byKey('network', key);++ const p = await byKey(membership.creator_id, key) || await byKey('network', key);if (!p || p.status !== 'active') continue;-- const bindings = bindingsOf(p.id).filter((b) => b.product === product).map((b) => ({ binding: b.binding, config: json(b.config, {}) }));++ const bindings = (await bindingsOf(p.id)).filter((b) => b.product === product).map((b) => ({ binding: b.binding, config: json(b.config, {}) }));if (bindings.length) out.push({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator', bindings });}return out;}-- function present(p, { withBindings = true } = {}) {++ async function present(p, { withBindings = true } = {}) {if (!p) return null;return {id: p.id, key: p.key, name: p.name, description: p.description || null, kind: p.kind, status: p.status,scope: p.creator_id === 'network' ? 'network' : 'creator', creator_id: p.creator_id,-- bindings: withBindings ? bindingsOf(p.id).map((b) => ({ product: b.product, binding: b.binding, config: json(b.config, {}) })) : undefined,++ bindings: withBindings ? (await bindingsOf(p.id)).map((b) => ({ product: b.product, binding: b.binding, config: json(b.config, {}) })) : undefined,updated_at: p.updated_at,};}@@@ -20,11 +20,11 @@ const { fail, prefixedId, iso, text, slug, slugify, json } = require('../util');const BILLING_KINDS = ['channel_subscription'];function createPlans({ db, now, outbox, creators, perks }) {-- const byId = (id) => db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;-- const version = (id) => db.prepare('SELECT * FROM vip_plan_versions WHERE id = ?').get(id) || null;-- const latestVersion = (planId) => db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC LIMIT 1').get(planId) || null;-- const versions = (planId) => db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC').all(planId);-- const versionPerks = (versionId) => db.prepare('SELECT * FROM vip_plan_perks WHERE plan_version_id = ? ORDER BY position').all(versionId);++ const byId = async (id) => await db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;++ const version = async (id) => await db.prepare('SELECT * FROM vip_plan_versions WHERE id = ?').get(id) || null;++ const latestVersion = async (planId) => await db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC LIMIT 1').get(planId) || null;++ const versions = async (planId) => await db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC').all(planId);++ const versionPerks = async (versionId) => await db.prepare('SELECT * FROM vip_plan_perks WHERE plan_version_id = ? ORDER BY position').all(versionId);function cleanBenefits(v) {if (v == null) return null;@@@ -35,24 +35,24 @@ function createPlans({ db, now, outbox, creators, perks }) {}/** Insert version N+1 of a plan from complete terms. Inside a transaction. */-- function insertVersion(plan, { name, description, benefits, perkRows, changeNote, actor, publish }) {++ async function insertVersion(plan, { name, description, benefits, perkRows, changeNote, actor, publish }) {const n = plan.latest_version + 1;const at = iso(now());const id = prefixedId('vpv', now());const perkSnap = perkRows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator' }));const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };terms.digest = crypto.createHash('sha256').update(JSON.stringify([terms.name, terms.description, terms.benefits, terms.perks, terms.billing_kind])).digest('hex').slice(0, 16);-- db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)++ await db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, plan.id, n, name, description, JSON.stringify(benefits), JSON.stringify(terms), changeNote || null, actor, at, publish ? at : null);const ins = db.prepare('INSERT INTO vip_plan_perks (plan_version_id, perk_id, perk_key, perk_name, position) VALUES (?, ?, ?, ?, ?)');-- perkRows.forEach((p, i) => ins.run(id, p.id, p.key, p.name, i));-- db.prepare(`UPDATE vip_plans SET latest_version = ?, current_version_id = CASE WHEN ? THEN ? ELSE current_version_id END, updated_at = ? WHERE id = ?`)++ for (const [i, p] of perkRows.entries()) await ins.run(id, p.id, p.key, p.name, i);++ await db.prepare(`UPDATE vip_plans SET latest_version = ?, current_version_id = CASE WHEN ? THEN ? ELSE current_version_id END, updated_at = ? WHERE id = ?`).run(n, publish ? 1 : 0, id, at, plan.id);-- return version(id);++ return await version(id);}-- function emitPublished(plan, v, traceparent) {-- const creator = creators.byId(plan.creator_id);++ async function emitPublished(plan, v, traceparent) {++ const creator = await creators.byId(plan.creator_id);outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,creator: creators.present(creator), billing_kind: plan.billing_kind,@@@ -61,117 +61,118 @@ function createPlans({ db, now, outbox, creators, perks }) {}function publishConflict(e) {-- if (e && /UNIQUE constraint failed: vip_plans\.creator_id, vip_plans\.billing_kind/.test(e.message)) {++ // PostgreSQL unique_violation (23505) on the one-published-plan-per-billing-kind index of vip_plans.++ if (e && e.code === '23505' && e.table === 'vip_plans' && /\(creator_id, billing_kind\)/.test(e.detail || '')) {fail(409, 'vip.plan.billing_product_taken', 'this creator already has a published plan sold as a channel subscription; Billing sells one per member and creator today, so archive the other plan first');}throw e;}-- function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, publish = false, changeNote, actor = null, traceparent }) {-- const creator = creators.byId(creatorId);++ async function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, publish = false, changeNote, actor = null, traceparent }) {++ const creator = await creators.byId(creatorId);if (!creator) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);const kind = billingKind === undefined ? (creator.kind === 'network' ? null : 'channel_subscription') : (billingKind || null);if (kind && !BILLING_KINDS.includes(kind)) fail(422, 'vip.invalid_input', `billing_kind must be one of ${BILLING_KINDS.join(', ')} or null`);if (kind && creator.kind === 'network') fail(422, 'vip.invalid_input', 'Billing has no network membership product yet; a network plan has billing_kind null');const cleanName = text(name, 'name', 80, { required: true });const planSlug = s ? slug(s) : slugify(cleanName);-- if (db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);-- const perkRows = perks.resolveForPlan(creatorId, perkRefs || []);++ if (await db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);++ const perkRows = await perks.resolveForPlan(creatorId, perkRefs || []);const at = iso(now());const id = prefixedId('vpl', now());try {-- return db.transaction(() => {-- db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, current_version_id, latest_version, created_by, created_at, updated_at)++ return await db.tx(async () => {++ await db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, current_version_id, latest_version, created_by, created_at, updated_at)VALUES (?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, actor, at, at);-- const v = insertVersion(byId(id), {++ const v = await insertVersion(await byId(id), {name: cleanName, description: text(description, 'description', 2000), benefits: cleanBenefits(benefits) || [],perkRows, changeNote: changeNote || 'created', actor, publish,});-- if (publish) emitPublished(byId(id), v, traceparent);-- return byId(id);-- })();++ if (publish) await emitPublished(await byId(id), v, traceparent);++ return await byId(id);++ });} catch (e) { return publishConflict(e); }}/** An edit: version N+1 from the latest terms plus the changes. Published plans publish it. */-- function update(planId, { name, description, benefits, perks: perkRefs, changeNote, actor = null, traceparent }) {-- const plan = byId(planId);++ async function update(planId, { name, description, benefits, perks: perkRefs, changeNote, actor = null, traceparent }) {++ const plan = await byId(planId);if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be edited');-- const last = latestVersion(planId);++ const last = await latestVersion(planId);const next = {name: name !== undefined ? text(name, 'name', 80, { required: true }) : last.name,description: description !== undefined ? text(description, 'description', 2000) : last.description,benefits: benefits !== undefined ? cleanBenefits(benefits) : json(last.benefits, []),};-- const perkRows = perkRefs !== undefined ? perks.resolveForPlan(plan.creator_id, perkRefs) : versionPerks(last.id).map((pp) => perks.byId(pp.perk_id)).filter((p) => p && p.status === 'active');++ const perkRows = perkRefs !== undefined ? await perks.resolveForPlan(plan.creator_id, perkRefs) : (await Promise.all((await versionPerks(last.id)).map(async (pp) => await perks.byId(pp.perk_id)))).filter((p) => p && p.status === 'active');const same = next.name === last.name && next.description === last.description && JSON.stringify(next.benefits) === last.benefits-- && JSON.stringify(perkRows.map((p) => p.id)) === JSON.stringify(versionPerks(last.id).map((pp) => pp.perk_id));++ && JSON.stringify(perkRows.map((p) => p.id)) === JSON.stringify((await versionPerks(last.id)).map((pp) => pp.perk_id));if (same) return { plan, version: last, unchanged: true };const publish = plan.status === 'published';-- return db.transaction(() => {-- const v = insertVersion(plan, { ...next, perkRows, changeNote: text(changeNote, 'change_note', 300), actor, publish });-- if (publish) emitPublished(byId(planId), v, traceparent);-- return { plan: byId(planId), version: v, unchanged: false };-- })();++ return await db.tx(async () => {++ const v = await insertVersion(plan, { ...next, perkRows, changeNote: text(changeNote, 'change_note', 300), actor, publish });++ if (publish) await emitPublished(await byId(planId), v, traceparent);++ return { plan: await byId(planId), version: v, unchanged: false };++ });}-- function publish(planId, { traceparent } = {}) {-- const plan = byId(planId);++ async function publish(planId, { traceparent } = {}) {++ const plan = await byId(planId);if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);if (plan.status === 'published') return { plan, replay: true };if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be published again; create a new plan');try {-- return db.transaction(() => {-- const v = latestVersion(planId);++ return await db.tx(async () => {++ const v = await latestVersion(planId);const at = iso(now());-- db.prepare('UPDATE vip_plan_versions SET published_at = ? WHERE id = ? AND published_at IS NULL').run(at, v.id);-- db.prepare("UPDATE vip_plans SET status = 'published', current_version_id = ?, updated_at = ? WHERE id = ?").run(v.id, at, planId);-- emitPublished(byId(planId), version(v.id), traceparent);-- return { plan: byId(planId), replay: false };-- })();++ await db.prepare('UPDATE vip_plan_versions SET published_at = ? WHERE id = ? AND published_at IS NULL').run(at, v.id);++ await db.prepare("UPDATE vip_plans SET status = 'published', current_version_id = ?, updated_at = ? WHERE id = ?").run(v.id, at, planId);++ await emitPublished(await byId(planId), await version(v.id), traceparent);++ return { plan: await byId(planId), replay: false };++ });} catch (e) { return publishConflict(e); }}/** No new members; existing members keep their terms until Billing ends their membership. */-- function archive(planId) {-- const plan = byId(planId);++ async function archive(planId) {++ const plan = await byId(planId);if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);if (plan.status === 'archived') return plan;const at = iso(now());-- db.prepare("UPDATE vip_plans SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(at, at, planId);-- return byId(planId);++ await db.prepare("UPDATE vip_plans SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(at, at, planId);++ return await byId(planId);}-- function list({ creatorId, includeDrafts = false, includeArchived = false }) {++ async function list({ creatorId, includeDrafts = false, includeArchived = false }) {const st = ['published'];if (includeDrafts) st.push('draft');if (includeArchived) st.push('archived');-- return db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);++ return await db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);}/** What a new member of this creator buys through Billing's product `kind`. */-- const purchasable = (creatorId, kind = 'channel_subscription') => db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND status = 'published'").get(creatorId, kind) || null;++ const purchasable = async (creatorId, kind = 'channel_subscription') => await db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND status = 'published'").get(creatorId, kind) || null;-- function presentVersion(v) {++ async function presentVersion(v) {if (!v) return null;return {id: v.id, version: v.version, name: v.name, description: v.description || null, benefits: json(v.benefits, []),-- perks: versionPerks(v.id).map((p) => ({ id: p.perk_id, key: p.perk_key, name: p.perk_name })),++ perks: (await versionPerks(v.id)).map((p) => ({ id: p.perk_id, key: p.perk_key, name: p.perk_name })),terms: json(v.terms, {}), change_note: v.change_note || null, created_at: v.created_at, published_at: v.published_at || null,};}-- function present(plan, { withVersions = false } = {}) {++ async function present(plan, { withVersions = false } = {}) {if (!plan) return null;-- const current = plan.current_version_id ? version(plan.current_version_id) : null;-- const latest = latestVersion(plan.id);++ const current = plan.current_version_id ? await version(plan.current_version_id) : null;++ const latest = await latestVersion(plan.id);return {id: plan.id, creator_id: plan.creator_id, slug: plan.slug, status: plan.status, billing_kind: plan.billing_kind,purchasable: plan.status === 'published' && !!plan.billing_kind,-- current_version: presentVersion(current),-- draft_version: latest && (!current || latest.version > current.version) ? presentVersion(latest) : null,-- versions: withVersions ? versions(plan.id).map(presentVersion) : undefined,++ current_version: await presentVersion(current),++ draft_version: latest && (!current || latest.version > current.version) ? await presentVersion(latest) : null,++ versions: withVersions ? (await Promise.all((await versions(plan.id)).map(presentVersion))) : undefined,created_at: plan.created_at, updated_at: plan.updated_at, archived_at: plan.archived_at || null,};}@@@ -25,53 +25,53 @@const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../util');function createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {-- const byId = (id) => db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;-- const forResource = (r, creatorId) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND creator_id = ? AND status = 'active'`)++ const byId = async (id) => await db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;++ const forResource = async (r, creatorId) => await db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND creator_id = ? AND status = 'active'`).get(r.service, r.type, r.id, creatorId) || null;/** The creator a product names as a resource's owner: SubjectRef, usr_ id, or 'network'. */-- function ownerCreator(owner) {-- if (owner === 'network') return creators.network();++ async function ownerCreator(owner) {++ if (owner === 'network') return await creators.network();const s = owner && typeof owner === 'object' ? owner.id : owner;-- return isUserSubject(String(s || '')) ? creators.bySubject(String(s)) : null;++ return isUserSubject(String(s || '')) ? await creators.bySubject(String(s)) : null;}/** Create or replace the active rule for a resource. */-- function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {-- const creator = creators.byId(creatorId);++ async function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {++ const creator = await creators.byId(creatorId);if (!creator || creator.kind !== 'creator') fail(404, 'vip.creator_not_found', 'gated resources belong to a creator');const r = entityRef(resource);if (!['member', 'plan', 'perk'].includes(requirement)) fail(422, 'vip.invalid_input', 'requirement must be member, plan or perk');if (requirement === 'plan') {-- const p = plans.byId(String(planId || ''));++ const p = await plans.byId(String(planId || ''));if (!p || p.creator_id !== creatorId) fail(422, 'vip.plan_not_found', 'plan_id must be one of this creator\'s plans');} else planId = null;if (requirement === 'perk') {perkKey = text(perkKey, 'perk_key', 48, { required: true });-- if (!perks.byKey(creatorId, perkKey) && !perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);++ if (!await perks.byKey(creatorId, perkKey) && !await perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);} else perkKey = null;-- const existing = forResource(r, creatorId);++ const existing = await forResource(r, creatorId);const at = iso(now());const id = prefixedId('vgr', now());-- db.transaction(() => {-- if (existing) db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(at, existing.id);-- db.prepare(`INSERT INTO vip_gated_resource_rules (id, creator_id, resource_service, resource_type, resource_id, requirement, plan_id, perk_key, sensitive, status, created_by, created_at, updated_at)++ await db.tx(async () => {++ if (existing) await db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(at, existing.id);++ await db.prepare(`INSERT INTO vip_gated_resource_rules (id, creator_id, resource_service, resource_type, resource_id, requirement, plan_id, perk_key, sensitive, status, created_by, created_at, updated_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, r.service, r.type, r.id, requirement, planId, perkKey, sensitive ? 1 : 0, actor, at, at);-- })();-- return byId(id);++ });++ return await byId(id);}-- function disable(id) {-- const rule = byId(id);++ async function disable(id) {++ const rule = await byId(id);if (!rule) fail(404, 'vip.rule_not_found', `no rule ${id}`);-- db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(iso(now()), id);-- return byId(id);++ await db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(iso(now()), id);++ return await byId(id);}-- const list = (creatorId) => db.prepare("SELECT * FROM vip_gated_resource_rules WHERE creator_id = ? AND status = 'active' ORDER BY created_at DESC").all(creatorId);++ const list = async (creatorId) => await db.prepare("SELECT * FROM vip_gated_resource_rules WHERE creator_id = ? AND status = 'active' ORDER BY created_at DESC").all(creatorId);-- function present(rule) {++ async function present(rule) {if (!rule) return null;-- const c = creators.byId(rule.creator_id);++ const c = await creators.byId(rule.creator_id);return {id: rule.id, creator: c && c.subject ? { type: 'user', id: c.subject } : null, creator_id: rule.creator_id,resource: { service: rule.resource_service, type: rule.resource_type, id: rule.resource_id },@@@ -94,10 +94,10 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem}/** The owner's active perks (own and network) that carry a product binding: the keys a member must hold. */-- function boundPerkKeys(creatorId, { product, binding }) {-- return perks.list({ creatorId, includeNetwork: true })-- .filter((p) => perks.bindingsOf(p.id).some((b) => b.product === product && b.binding === binding))-- .map((p) => p.key);++ async function boundPerkKeys(creatorId, { product, binding }) {++ const list = await perks.list({ creatorId, includeNetwork: true });++ const bound = await Promise.all(list.map(async (p) => (await perks.bindingsOf(p.id)).some((b) => b.product === product && b.binding === binding)));++ return list.filter((_, i) => bound[i]).map((p) => p.key);}/** evaluate() when the owner has no rule and the product passed its default gate. */@@@ -114,9 +114,9 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlemif (ent.status === 'unknown') return deny('entitlement_unknown', { ...base, entitlement: ent });if (!ent.active) return deny('not_a_member', { ...base, entitlement: ent });if (fb.binding) {-- const needed = boundPerkKeys(oc.id, fb.binding);++ const needed = await boundPerkKeys(oc.id, fb.binding);if (needed.length) {-- const held = memberships.perkKeys(memberships.get(subject, oc.id));++ const held = await memberships.perkKeys(await memberships.get(subject, oc.id));if (!needed.some((k) => held.includes(k))) return deny('perk_missing', { ...base, entitlement: ent });}}@@@ -134,35 +134,35 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlemlet fb;try { fb = cleanFallback(fallback); } catch { return deny('invalid_fallback'); }if (!owner) return deny('owner_required');-- let oc = ownerCreator(owner);++ let oc = await ownerCreator(owner);// A creator VIP has never seen can still hold Billing subscriptions: the default gate asks for them.if (!oc && fb && !ruleId && owner !== 'network') {const s = owner && typeof owner === 'object' ? owner.id : owner;if (isUserSubject(String(s || ''))) oc = { id: null, subject: String(s), status: 'active', kind: 'creator' };}if (!oc) return deny('no_rule');-- const rule = ruleId ? byId(String(ruleId)) : (oc.id ? forResource(r, oc.id) : null);-- if (!rule && fb && !ruleId) return evaluateFallback({ subject, oc, fb, mode, traceparent });++ const rule = ruleId ? await byId(String(ruleId)) : (oc.id ? await forResource(r, oc.id) : null);++ if (!rule && fb && !ruleId) return await evaluateFallback({ subject, oc, fb, mode, traceparent });if (!rule) return deny('no_rule');-- if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });-- if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: present(rule) });-- if (rule.status !== 'active') return deny('rule_disabled', { rule: present(rule) });-- const creator = creators.byId(rule.creator_id);-- if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: present(rule) });-- if (!subject) return deny('not_signed_in', { rule: present(rule) });-- if (!isUserSubject(subject)) return deny('not_a_member', { rule: present(rule) });++ if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: await present(rule) });++ if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: await present(rule) });++ if (rule.status !== 'active') return deny('rule_disabled', { rule: await present(rule) });++ const creator = await creators.byId(rule.creator_id);++ if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: await present(rule) });++ if (!subject) return deny('not_signed_in', { rule: await present(rule) });++ if (!isUserSubject(subject)) return deny('not_a_member', { rule: await present(rule) });// The creator always sees their own gated resources.-- if (subject === creator.subject) return { allow: true, reason: 'owner', rule: present(rule), entitlement: null };++ if (subject === creator.subject) return { allow: true, reason: 'owner', rule: await present(rule), entitlement: null };let ent;try {ent = await entitlements.check(subject, creator.subject, { mode: rule.sensitive ? 'authoritative' : (mode === 'authoritative' ? 'authoritative' : 'auto'), traceparent });-- } catch { return deny('entitlement_unknown', { rule: present(rule) }); }-- if (ent.status === 'unknown') return deny('entitlement_unknown', { rule: present(rule), entitlement: ent });-- if (!ent.active) return deny('not_a_member', { rule: present(rule), entitlement: ent });-- const m = memberships.get(subject, creator.id);-- if (rule.requirement === 'plan' && (!m || m.plan_id !== rule.plan_id)) return deny('plan_required', { rule: present(rule), entitlement: ent });-- if (rule.requirement === 'perk' && !memberships.perkKeys(m).includes(rule.perk_key)) return deny('perk_missing', { rule: present(rule), entitlement: ent });-- return { allow: true, reason: 'member', rule: present(rule), entitlement: ent };++ } catch { return deny('entitlement_unknown', { rule: await present(rule) }); }++ if (ent.status === 'unknown') return deny('entitlement_unknown', { rule: await present(rule), entitlement: ent });++ if (!ent.active) return deny('not_a_member', { rule: await present(rule), entitlement: ent });++ const m = await memberships.get(subject, creator.id);++ if (rule.requirement === 'plan' && (!m || m.plan_id !== rule.plan_id)) return deny('plan_required', { rule: await present(rule), entitlement: ent });++ if (rule.requirement === 'perk' && !(await memberships.perkKeys(m)).includes(rule.perk_key)) return deny('perk_missing', { rule: await present(rule), entitlement: ent });++ return { allow: true, reason: 'member', rule: await present(rule), entitlement: ent };}return { byId, forResource, ownerCreator, set, disable, list, present, evaluate };@@@ -18,18 +18,17 @@*/const express = require('express');const { http } = require('openvibe-contracts');--const { parseDelivery, createInbox } = require('openvibe-sdk/events');++const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');const { isUserSubject } = require('../util');const CONSUMER = 'vip-billing';function consumerRouter({ domain, config, log = console }) {const router = express.Router();-- const inbox = createInbox(domain.db, { now: domain.now });-- inbox.ensureSchema();++ const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sqlconst { entitlements } = domain;-- function handle(event) {++ async function handle(event) {const p = event.payload && typeof event.payload === 'object' ? event.payload : {};const asOf = Date.parse(event.timestamp);if (!Number.isFinite(asOf)) return 'ignored:bad_timestamp';@@@ -39,21 +38,21 @@ function consumerRouter({ domain, config, log = console }) {if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';if (p.kind && p.kind !== entitlements.KIND) return 'ignored:kind';const sub = p.subscription || null;-- return entitlements.apply({++ return (await entitlements.apply({member, creator, active: !!p.active, expiresAt: p.expires_at || null,cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,subscriptionStatus: sub ? sub.status : undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,-- }).outcome;++ })).outcome;}if (event.event_type === 'billing.subscription.canceled') {const s = p.subscription || {};const member = s.subscriber && s.subscriber.id;const creator = s.streamer && s.streamer.id;if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';-- return entitlements.applyCanceled({++ return (await entitlements.applyCanceled({member, creator, subscriptionId: s.id || null, subscriptionStatus: s.status || null,currentPeriodEnd: s.current_period_end || null, eventId: event.event_id, asOf,-- }).outcome;++ })).outcome;}if (event.event_type === 'billing.transaction.reversed') {if (!(Number(p.entitlements_revoked) > 0)) return 'ignored:no_entitlement';@@@ -61,18 +60,18 @@ function consumerRouter({ domain, config, log = console }) {const member = p.to_subject;const creator = p.from_subject;if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';-- return entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';++ return await entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';}return 'ignored:type';}/** Apply one envelope (also used by tests). Returns { duplicate, outcome }. */-- function apply(event) {-- const r = inbox.once(CONSUMER, event.event_id, () => (event.source !== 'billing' ? 'ignored:source' : handle(event)));++ async function apply(event) {++ const r = await inbox.once(CONSUMER, event.event_id, async () => (event.source !== 'billing' ? 'ignored:source' : await handle(event)));return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };}-- router.post('/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {++ router.post('/events', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {const secrets = config.events.webhookSecrets;if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);@@@ -86,7 +85,7 @@ function consumerRouter({ domain, config, log = console }) {}let out;try {-- out = apply(event);++ out = await apply(event);} catch (e) {// Not acknowledged: Events retries it, and the inbox claim rolled back with the change.log.error(`[VIP] event ${event.event_id} (${event.event_type}) failed:`, e.message);@@@ -17,7 +17,7 @@const { validate } = require('openvibe-contracts');const { createClient } = require('openvibe-sdk/core');const { createServiceTokenClient } = require('openvibe-sdk/auth');--const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');const ACTOR = { type: 'service', id: 'vip' };@@@ -35,7 +35,9 @@ function createVipOutbox({ db, config, fetchImpl, now = () => Date.now(), log =}const events = createEventsClient(createClient(clientOpts), { source: 'vip' });let lastError = null;-- const outbox = createOutbox(db, {++ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient++ // transaction); several processes relay one table safely (leases).++ const outbox = createPgOutbox(db, {events,intervalMs: config.events.intervalMs,now,@@@ -45,14 +47,13 @@ function createVipOutbox({ db, config, fetchImpl, now = () => Date.now(), log =lastError = msg;},});-- outbox.ensureSchema();/** Inside the caller's transaction. */function emit(eventType, subject, payload, { visibility = 'internal', priority = 'important', traceparent } = {}) {const env = events.prepare({ event_type: eventType, actor: ACTOR, subject, payload, visibility, priority }, { now: now() });const v = validate('events.event-envelope@1', env);if (!v.valid) throw new Error(`outbox: invalid envelope for ${eventType}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);-- return outbox.enqueue(env, { traceparent });++ return outbox.enqueue(db, env, { traceparent });}return {@@@ -30,8 +30,8 @@*/const { iso } = require('../util');--const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);--const columns = (db, t) => (tableExists(db, t) ? db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name) : []);++const tableExists = async (db, name) => !!await db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);++const columns = async (db, t) => (await tableExists(db, t) ? (await db.prepare(`PRAGMA table_info(${t})`).all()).map((c) => c.name) : []);const CREATOR_ROLES = ['streamer', 'global_mod', 'admin'];const PLAN_SLUG = 'channel-subscription';const PERK_KEY = 'subscriber-badge';@@@ -53,16 +53,16 @@ function billingSnapshotSource(bdb, { now = () => Date.now() } = {}) {const subs = bdb.prepare('SELECT * FROM subscriptions WHERE streamer = ? ORDER BY created_at');return {name: 'billing snapshot',-- listSubscriptions: async (creator) => subs.all(creator).map((s) => ({++ listSubscriptions: async (creator) => (await subs.all(creator)).map((s) => ({id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, status: s.status,auto_renew: !!s.auto_renew, cancel_at_period_end: !!s.cancel_at_period_end, current_period_end: s.current_period_end, legacy_live_id: s.legacy_live_id ?? null,})),entitlement: async (member, creator) => {const at = iso(now());-- const rows = bdb.prepare(`SELECT starts_at, ends_at FROM entitlements WHERE subject = ? AND kind = 'channel_subscription' AND scope = ?++ const rows = await bdb.prepare(`SELECT starts_at, ends_at FROM entitlements WHERE subject = ? AND kind = 'channel_subscription' AND scope = ?AND revoked_at IS NULL AND ends_at > ? ORDER BY starts_at`).all(member, creator, at);const active = rows.some((r) => r.starts_at <= at);-- const sub = bdb.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(member, creator);++ const sub = await bdb.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(member, creator);return {active, expires_at: active ? rows.reduce((m, r) => (r.ends_at > m ? r.ends_at : m), '') : null,subscription: sub ? { id: sub.id, status: sub.status, auto_renew: !!sub.auto_renew, cancel_at_period_end: !!sub.cancel_at_period_end } : null,@@@ -87,16 +87,16 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu};// ── Read the snapshot ────────────────────────────────────-- const hasUsers = tableExists(live, 'users');-- const userCols = columns(live, 'users');-- const users = hasUsers ? live.prepare(`SELECT id, username, display_name${userCols.includes('role') ? ', role' : ", 'user' AS role"} FROM users`).all() : [];++ const hasUsers = await tableExists(live, 'users');++ const userCols = await columns(live, 'users');++ const users = hasUsers ? await live.prepare(`SELECT id, username, display_name${userCols.includes('role') ? ', role' : ", 'user' AS role"} FROM users`).all() : [];const byLiveId = new Map(users.map((u) => [String(u.id), u]));-- const subCols = columns(live, 'subscriptions');-- const liveSubs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];-- if (tableExists(live, 'site_settings')) {-- const get = (k) => { const r = live.prepare('SELECT value FROM site_settings WHERE key = ?').get(k); return r ? r.value : null; };++ const subCols = await columns(live, 'subscriptions');++ const liveSubs = subCols.length ? await live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];++ if (await tableExists(live, 'site_settings')) {++ const get = async (k) => { const r = await live.prepare('SELECT value FROM site_settings WHERE key = ?').get(k); return r ? r.value : null; };report.live_offering = {-- sub_price_usd: get('sub_price_usd'), sub_streamer_share_pct: get('sub_streamer_share_pct'), sub_site_route_fee_pct: get('sub_site_route_fee_pct'),++ sub_price_usd: await get('sub_price_usd'), sub_streamer_share_pct: await get('sub_streamer_share_pct'), sub_site_route_fee_pct: await get('sub_site_route_fee_pct'),note: 'recorded for reference only: OpenVibe.Billing prices and charges subscriptions; VIP plan terms carry no price',};}@@@ -136,13 +136,13 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRuVALUES (@source, @table, @id, @target_type, @target_id, @status, @reason, @run, @at, @at)ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,reason = excluded.reason, run_id = excluded.run_id, updated_at = excluded.updated_at`);-- const record = (source, table, id, fields) => upsertMap.run({ source, table, id: String(id), target_type: null, target_id: null, reason: null, run: runId, at, ...fields });++ const record = async (source, table, id, fields) => await upsertMap.run({ source, table, id: String(id), target_type: null, target_id: null, reason: null, run: runId, at, ...fields });-- const run = () => {++ const run = async () => {// ── 1. The network perk ──────────────────────────────-- let perk = perks.byKey('network', PERK_KEY);++ let perk = await perks.byKey('network', PERK_KEY);if (!perk) {-- perk = perks.create({++ perk = await perks.create({creatorId: 'network', key: PERK_KEY, name: 'Subscriber badge', kind: 'badge', actor: ACTOR,description: 'A star badge next to your name in the creator\'s chat, as channel subscribers had on OpenVibe.Live.',bindings: [@@@ -152,48 +152,48 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu{ product: 'chat', binding: 'badge', config: { badge: 'subscriber' } },],});-- record('live', 'offering', 'subscriber_badge', { target_type: 'perk', target_id: perk.id, status: 'imported' });++ await record('live', 'offering', 'subscriber_badge', { target_type: 'perk', target_id: perk.id, status: 'imported' });}// ── 2 + 3. Creators and their plan ───────────────────for (const id of [...creatorIds].sort((a, b) => Number(a) - Number(b))) {-- const prev = mapRow.get('live', 'users', id);++ const prev = await mapRow.get('live', 'users', id);const subject = subjectOf(id);const u = byLiveId.get(id);if (!subject) {const reason = `Live user ${id}${u ? ` (${u.username})` : ''} has no Network subject yet`;-- record('live', 'users', id, { status: 'held', reason });++ await record('live', 'users', id, { status: 'held', reason });report.counts.creators.held++; report.holds.push({ live_user: Number(id), reason });continue;}-- const existed = creators.bySubject(subject);-- const c = creators.ensure({ subject, username: handleOf(id), displayName: u ? u.display_name : null, origin: existed ? existed.origin : 'import' });++ const existed = await creators.bySubject(subject);++ const c = await creators.ensure({ subject, username: handleOf(id), displayName: u ? u.display_name : null, origin: existed ? existed.origin : 'import' });if (prev && prev.status !== 'held') report.counts.creators.unchanged++;else { report.counts.creators[prev ? 'released' : 'imported']++; }-- record('live', 'users', id, { target_type: 'creator', target_id: c.id, status: 'imported', reason: prev && prev.status === 'held' ? 'released from hold' : null });++ await record('live', 'users', id, { target_type: 'creator', target_id: c.id, status: 'imported', reason: prev && prev.status === 'held' ? 'released from hold' : null });-- const offer = mapRow.get('live', 'offering', `channel_subscription:${id}`);-- if (offer && offer.target_id && plans.byId(offer.target_id)) { report.counts.plans.unchanged++; continue; }-- const own = plans.purchasable(c.id);++ const offer = await mapRow.get('live', 'offering', `channel_subscription:${id}`);++ if (offer && offer.target_id && await plans.byId(offer.target_id)) { report.counts.plans.unchanged++; continue; }++ const own = await plans.purchasable(c.id);if (own) {-- record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: own.id, status: 'linked', reason: 'the creator already publishes a plan sold as the channel subscription' });++ await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: own.id, status: 'linked', reason: 'the creator already publishes a plan sold as the channel subscription' });report.counts.plans.linked++;continue;}-- const existingSlug = db.prepare('SELECT * FROM vip_plans WHERE creator_id = ? AND slug = ?').get(c.id, PLAN_SLUG);++ const existingSlug = await db.prepare('SELECT * FROM vip_plans WHERE creator_id = ? AND slug = ?').get(c.id, PLAN_SLUG);if (existingSlug) {-- record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: existingSlug.id, status: 'linked', reason: `plan ${PLAN_SLUG} already exists (status ${existingSlug.status})` });++ await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: existingSlug.id, status: 'linked', reason: `plan ${PLAN_SLUG} already exists (status ${existingSlug.status})` });report.counts.plans.linked++;continue;}const name = c.display_name || c.username || 'this creator';-- const plan = plans.create({++ const plan = await plans.create({creatorId: c.id, slug: PLAN_SLUG, name: 'Channel subscription',description: `A monthly subscription to ${name}'s channel, carried over from OpenVibe.Live.`,benefits: [`A subscriber badge next to your name in ${name}'s chat`],perks: [PERK_KEY], billingKind: 'channel_subscription', publish: true, changeNote: 'imported from OpenVibe.Live', actor: ACTOR,});-- record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: plan.id, status: 'imported' });++ await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: plan.id, status: 'imported' });report.counts.plans.created++;}@@@ -202,17 +202,17 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRuif (!list) continue;for (const sub of list) {const member = sub.subscriber && sub.subscriber.id;-- const prev = mapRow.get('billing', 'subscriptions', sub.id);++ const prev = await mapRow.get('billing', 'subscriptions', sub.id);if (sub.status !== 'active') {if (!prev) {const reason = `Billing subscription is ${sub.status}: no current membership to file terms for`;-- record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });++ await record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });} else report.counts.memberships.unchanged++;continue;}const e = ents.get(`${member}:${creatorSubject}`) || { active: false, expires_at: null };-- const out = entitlements.apply({++ const out = await entitlements.apply({member, creator: creatorSubject, active: !!e.active, expiresAt: e.expires_at || null,cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id, subscriptionStatus: sub.status,reason: 'imported', source: 'import', origin: 'import', asOf: domain.now(),@@@ -220,27 +220,27 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRuconst m = out.membership;if (!e.active || !m) {const reason = 'Billing lists the subscription as active but its entitlement is not active now';-- record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });++ await record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });continue;}if (prev && prev.status === 'imported' && prev.target_id === m.id) report.counts.memberships.unchanged++;else report.counts.memberships.imported++;-- record('billing', 'subscriptions', sub.id, { target_type: 'membership', target_id: m.id, status: 'imported' });++ await record('billing', 'subscriptions', sub.id, { target_type: 'membership', target_id: m.id, status: 'imported' });}}// ── 5. Live's subscriptions rows ─────────────────────const membershipFor = db.prepare('SELECT m.id FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?');for (const s of liveSubs) {-- const prev = mapRow.get('live', 'subscriptions', String(s.id));++ const prev = await mapRow.get('live', 'subscriptions', String(s.id));const member = subjectOf(s.subscriber_id);const creator = subjectOf(s.streamer_id);let fields;if (!member || !creator) {fields = { status: 'held', reason: `${!member ? `subscriber (Live user ${s.subscriber_id})` : `creator (Live user ${s.streamer_id})`} has no Network subject yet` };} else {-- const m = membershipFor.get(member, creator);++ const m = await membershipFor.get(member, creator);const bsub = (billingSubs.get(creator) || []).find((x) => x.subscriber && x.subscriber.id === member);if (m) fields = { status: 'linked', target_type: 'membership', target_id: m.id };else if (bsub) fields = { status: 'linked', target_type: 'billing_subscription', target_id: bsub.id, reason: `Billing holds it (status ${bsub.status}); no current membership` };@@@ -248,7 +248,7 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRuelse fields = { status: 'held', reason: 'Billing has no subscription for this pair (run Billing\'s Live import first)' };}if (prev && prev.status === fields.status && prev.target_id === (fields.target_id || null)) { report.counts.live_subscriptions.unchanged++; continue; }-- record('live', 'subscriptions', s.id, fields);++ await record('live', 'subscriptions', s.id, fields);report.counts.live_subscriptions[fields.status === 'held' ? 'held' : 'linked']++;if (fields.status === 'held') report.holds.push({ live_subscription: s.id, reason: fields.reason });}@@@ -256,7 +256,7 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu};try {-- db.transaction(run)();++ await db.tx(run);} catch (e) {if (!(e instanceof DryRun)) throw e;}@@@ -16,20 +16,21 @@ if (config.isProduction && !config.formSecret) {console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');process.exit(1);}--const app = createApp({ config });++(async () => {++const app = await createApp({ config });const { domain, keys, outbox, metrics } = app.locals;keys.start();const timers = [];if (config.jobs.enabled) {const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };-- every(config.jobs.refreshIntervalMs, () => domain.entitlements.refreshDue());-- every(6 * 3600 * 1000, () => outbox.outbox.prune());++ every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());++ every(6 * 3600 * 1000, async () => await outbox.outbox.prune());outbox.start();}const server = app.listen(config.port, config.host, () => {-- console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${domain.db.store})`);console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);});server.keepAliveTimeout = 65_000;@@@ -40,8 +41,9 @@ function shutdown(signal) {outbox.stop();keys.stop();if (metrics && metrics.stop) metrics.stop();-- server.close(() => { try { domain.db.close(); } catch { /* */ } process.exit(0); });++ server.close(() => { Promise.resolve().then(() => domain.db.close()).catch(() => {}).finally(() => process.exit(0)); });setTimeout(() => process.exit(0), 5000).unref();}process.on('SIGTERM', () => shutdown('SIGTERM'));process.on('SIGINT', () => shutdown('SIGINT'));++})().catch((err) => { console.error('[VIP] failed to start:', err); process.exit(1); });@@@ -39,7 +39,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = consoleif (config.network.publicKey || timer) return;const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });retry();-- timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);++ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);timer.unref();}function stop() { if (timer) clearInterval(timer); timer = null; }@@@ -94,7 +94,7 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {body: JSON.stringify(body),signal: AbortSignal.timeout(10000),});-- if (res.status === 401 && !retried) { tokenClient().invalidate(); return post(body, true); }++ if (res.status === 401 && !retried) { tokenClient().invalidate(); return await post(body, true); }const data = await res.json().catch(() => null);if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);return data.results;@@@ -16,12 +16,21 @@*/const { createReadiness } = require('openvibe-shared/ready');--function createVipReadiness({ db, keys, config, outbox, now, release = null, fetchImpl = globalThis.fetch }) {++function createVipReadiness({ db, keys, config, outbox, now, release = null, fetchImpl = globalThis.fetch, valkey = null }) {return createReadiness({service: 'vip',release,checks: [-- { name: 'db', required: true, check: () => db.prepare('SELECT COUNT(*) AS n FROM vip_creators').get().n >= 1 || 'database has no network creator row' },++ {++ name: 'db', required: true,++ // A real round trip that names the store (postgresql / pglite), and the network creator row present.++ check: async () => {++ const r = await db.ready();++ if (!r.ok) return r.error;++ return (await db.prepare('SELECT COUNT(*) AS n FROM vip_creators').get()).n >= 1 ? { ok: true, detail: r.detail } : 'database has no network creator row';++ },++ },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },{name: 'network_jwks', required: false,check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified'),@@@ -35,10 +44,10 @@ function createVipReadiness({ db, keys, config, outbox, now, release = null, fet},},],-- details: (body) => {++ details: async (body) => {if (body.checks.db.status !== 'ok') return {};const t = now();-- const p = db.prepare('SELECT COUNT(*) AS n, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t);++ const p = await db.prepare('SELECT COUNT(*) AS n, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t);return {events: outbox.status(),billing_events_accepted: config.events.webhookSecrets.length > 0,@@@ -85,7 +85,7 @@ function createEmbedRoutes({ domain, config }) {/** The creator and its card, or null (unknown handle, suspended, no published plan). */async function lookup(username) {if (!USERNAME_RE.test(String(username || ''))) return null;-- const c = creators.byUsername(username);++ const c = await creators.byUsername(username);const card = c ? await cards.card(c) : null;return card ? { c, card } : null;}@@@ -74,16 +74,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {}router.get('/robots.txt', (req, res) => res.type('text/plain').send(`User-agent: *\nDisallow: /me\nDisallow: /dashboard\nDisallow: /auth/\nDisallow: /api/\nDisallow: /embed/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));-- router.get('/sitemap.xml', (req, res) => {-- const urls = [`${config.baseUrl}/`, ...creators.listPublic({ limit: 5000 }).map((c) => `${config.baseUrl}/${encodeURIComponent(c.username)}`)];++ router.get('/sitemap.xml', async (req, res) => {++ const urls = [`${config.baseUrl}/`, ...(await creators.listPublic({ limit: 5000 })).map((c) => `${config.baseUrl}/${encodeURIComponent(c.username)}`)];res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${u.replace(/&/g, '&')}</loc></url>`).join('\n')}\n</urlset>\n`);});// What shipped on OpenVibe.VIP: the shared update log every OpenVibe site has.router.get('/updates', (req, res) => send(res, 200, { viewer: req.viewer, canonicalPath: '/updates', title: 'What shipped on OpenVibe.VIP', body: frame.updatesBody({ service: 'vip', siteName: 'OpenVibe.VIP' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));-- router.get('/', (req, res) => send(res, 200, {++ router.get('/', async (req, res) => send(res, 200, {active: 'home', viewer: req.viewer, canonicalPath: '/',-- body: pages.home({ creators: creators.listPublic().map(creators.present) }),++ body: pages.home({ creators: (await creators.listPublic()).map(creators.present) }),}));// ── The member's page ────────────────────────────────────@@@ -91,10 +91,10 @@ function createWebRoutes({ domain, config, layout, userAuth }) {try {const rows = [];if (req.viewer) {-- for (const m of memberships.forMember(req.viewer.subject)) {-- const c = creators.byId(m.creator_id);++ for (const m of await memberships.forMember(req.viewer.subject)) {++ const c = await creators.byId(m.creator_id);const e = c && c.subject ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;-- rows.push({ membership: memberships.present(m), creator: creators.present(c), entitlement: e, preferences: memberships.preferences(req.viewer.subject, m.creator_id) });++ rows.push({ membership: await memberships.present(m), creator: creators.present(c), entitlement: e, preferences: await memberships.preferences(req.viewer.subject, m.creator_id) });}}send(res, 200, { title: 'Your memberships', active: 'memberships', robots: 'noindex,nofollow', canonicalPath: '/me', viewer: req.viewer, body: pages.memberPage({ viewer: req.viewer, rows, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query }) });@@@ -105,49 +105,49 @@ function createWebRoutes({ domain, config, layout, userAuth }) {back(res, to, 'ok', out.already ? 'That membership was already set to end.' : `Cancelled. Your membership runs until ${pages.fmtDate(out.expires_at)}.`);}, { backTo: '/me' });post('/me/:creatorId/preferences', async (req, res, to) => {-- const c = creators.byId(req.params.creatorId);-- if (!c || !memberships.get(req.viewer.subject, c.id)) throw new VipError(404, 'vip.membership_not_found', 'no such membership');-- memberships.setPreferences(req.viewer.subject, c.id, { showBadge: bool(req.body.show_badge) });++ const c = await creators.byId(req.params.creatorId);++ if (!c || !await memberships.get(req.viewer.subject, c.id)) throw new VipError(404, 'vip.membership_not_found', 'no such membership');++ await memberships.setPreferences(req.viewer.subject, c.id, { showBadge: bool(req.body.show_badge) });back(res, to, 'ok', 'Saved.');}, { backTo: '/me' });// ── Creator dashboard ────────────────────────────────────/** The creator the dashboard acts for: the viewer, or the network for staff (?as=network). */-- function dashCreator(req) {++ async function dashCreator(req) {const asNetwork = (req.query.as || (req.body && req.body.as)) === 'network';if (asNetwork) {if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff manage network plans');-- return creators.network();++ return await creators.network();}-- return creators.ensure({ subject: req.viewer.subject, username: req.viewer.username, displayName: req.viewer.name, origin: 'self' });++ return await creators.ensure({ subject: req.viewer.subject, username: req.viewer.username, displayName: req.viewer.name, origin: 'self' });}router.get('/dashboard', async (req, res, next) => {try {if (!req.viewer) return send(res, 200, { title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', viewer: null, body: pages.dashboard({ viewer: null }) });let creator;-- try { creator = dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }++ try { creator = await dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }const isNetwork = creator.kind === 'network';-- const planRows = plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true }).map((p) => plans.present(p, { withVersions: true }));-- const allPerks = perks.list({ creatorId: creator.id }).map((p) => perks.present(p, { withBindings: false }));-- const ownPerks = perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true }).map((p) => perks.present(p));++ const planRows = (await Promise.all((await plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true })).map(async (p) => await plans.present(p, { withVersions: true }))));++ const allPerks = (await Promise.all((await perks.list({ creatorId: creator.id })).map(async (p) => await perks.present(p, { withBindings: false }))));++ const ownPerks = (await Promise.all((await perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true })).map(async (p) => await perks.present(p))));let members = [];let membersSource = 'billing';if (!isNetwork) {-- const byMember = new Map(memberships.forCreator(creator.id).map((m) => [m.member_subject, m]));++ const byMember = new Map((await memberships.forCreator(creator.id)).map((m) => [m.member_subject, m]));try {const out = await billing.listSubscriptions({ streamer: creator.subject, status: 'active' });-- members = (out.subscriptions || []).map((s) => ({ member: s.subscriber, current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, membership: memberships.present(byMember.get(s.subscriber.id)) }));++ members = (await Promise.all((out.subscriptions || []).map(async (s) => ({ member: s.subscriber, current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, membership: await memberships.present(byMember.get(s.subscriber.id)) }))));} catch {membersSource = 'projection';-- members = entitlements.projectedMembers(creator.subject).map((pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: memberships.present(byMember.get(pr.member_subject)) }));++ members = (await Promise.all((await entitlements.projectedMembers(creator.subject)).map(async (pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: await memberships.present(byMember.get(pr.member_subject)) }))));}}send(res, 200, {title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,body: pages.dashboard({viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource,-- rules: isNetwork ? [] : policies.list(creator.id).map(policies.present), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',++ rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,}),});@@@ -157,64 +157,64 @@ function createWebRoutes({ domain, config, layout, userAuth }) {// Dashboard writes: /dashboard/... for the viewer's own creator, /dashboard/network/... for staff.for (const base of ['/dashboard', '/dashboard/network']) {const withAs = (req) => { if (base === '/dashboard/network') req.query.as = 'network'; };-- const own = (req, row) => { withAs(req); const c = dashCreator(req); if (!row || row.creator_id !== c.id) throw new VipError(404, 'vip.not_found', 'not found'); return c; };++ const own = async (req, row) => { withAs(req); const c = await dashCreator(req); if (!row || row.creator_id !== c.id) throw new VipError(404, 'vip.not_found', 'not found'); return c; };const opts = { backTo: () => (base === '/dashboard/network' ? '/dashboard?as=network' : '/dashboard') };post(`${base}/profile`, async (req, res, to) => {withAs(req);-- const c = dashCreator(req);-- creators.update(c.id, { displayName: req.body.display_name, bio: req.body.bio, showMemberCount: c.kind === 'creator' ? bool(req.body.show_member_count) : undefined });++ const c = await dashCreator(req);++ await creators.update(c.id, { displayName: req.body.display_name, bio: req.body.bio, showMemberCount: c.kind === 'creator' ? bool(req.body.show_member_count) : undefined });back(res, to, 'ok', 'Profile saved.');}, opts);post(`${base}/plans`, async (req, res, to) => {withAs(req);-- const c = dashCreator(req);-- const plan = plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });++ const c = await dashCreator(req);++ const plan = await plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}.`);}, opts);post(`${base}/plans/:id`, async (req, res, to) => {-- const plan = plans.byId(req.params.id);-- own(req, plan);-- const out = plans.update(plan.id, { name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), changeNote: req.body.change_note, actor: req.viewer.subject, traceparent: req.ov.traceparent });++ const plan = await plans.byId(req.params.id);++ await own(req, plan);++ const out = await plans.update(plan.id, { name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), changeNote: req.body.change_note, actor: req.viewer.subject, traceparent: req.ov.traceparent });back(res, to, 'ok', out.unchanged ? 'Nothing changed.' : `Saved as version ${out.version.version}.`);}, opts);post(`${base}/plans/:id/publish`, async (req, res, to) => {-- const plan = plans.byId(req.params.id);-- own(req, plan);-- plans.publish(plan.id, { traceparent: req.ov.traceparent });++ const plan = await plans.byId(req.params.id);++ await own(req, plan);++ await plans.publish(plan.id, { traceparent: req.ov.traceparent });back(res, to, 'ok', 'Published.');}, opts);post(`${base}/plans/:id/archive`, async (req, res, to) => {-- const plan = plans.byId(req.params.id);-- own(req, plan);-- plans.archive(plan.id);++ const plan = await plans.byId(req.params.id);++ await own(req, plan);++ await plans.archive(plan.id);back(res, to, 'ok', 'Archived. Current members keep their terms until their membership ends.');}, opts);const bindingLines = (v) => lines(v).map((l) => { const [product, binding] = l.split(/\s+/); return { product, binding }; });post(`${base}/perks`, async (req, res, to) => {withAs(req);-- const c = dashCreator(req);-- perks.create({ creatorId: c.id, key: req.body.key || undefined, name: req.body.name, description: req.body.description, kind: req.body.kind || 'other', bindings: bindingLines(req.body.bindings), actor: req.viewer.subject });++ const c = await dashCreator(req);++ await perks.create({ creatorId: c.id, key: req.body.key || undefined, name: req.body.name, description: req.body.description, kind: req.body.kind || 'other', bindings: bindingLines(req.body.bindings), actor: req.viewer.subject });back(res, to, 'ok', 'Perk created.');}, opts);post(`${base}/perks/:id`, async (req, res, to) => {-- const p = perks.byId(req.params.id);-- own(req, p);-- perks.update(p.id, { name: req.body.name, description: req.body.description, status: req.body.status, bindings: bindingLines(req.body.bindings) });++ const p = await perks.byId(req.params.id);++ await own(req, p);++ await perks.update(p.id, { name: req.body.name, description: req.body.description, status: req.body.status, bindings: bindingLines(req.body.bindings) });back(res, to, 'ok', 'Perk saved.');}, opts);if (base === '/dashboard') {post(`${base}/rules`, async (req, res, to) => {-- const c = dashCreator(req);-- policies.set({++ const c = await dashCreator(req);++ await policies.set({creatorId: c.id, resource: { service: req.body.service, type: req.body.type, id: req.body.id }, requirement: req.body.requirement || 'member',planId: req.body.plan_id || null, perkKey: req.body.perk_key || null, sensitive: bool(req.body.sensitive), actor: req.viewer.subject,});back(res, to, 'ok', 'Rule saved.');}, opts);post(`${base}/rules/:id/disable`, async (req, res, to) => {-- const rule = policies.byId(req.params.id);-- own(req, rule);-- policies.disable(rule.id);++ const rule = await policies.byId(req.params.id);++ await own(req, rule);++ await policies.disable(rule.id);back(res, to, 'ok', 'Rule removed.');}, opts);}@@@ -224,10 +224,10 @@ function createWebRoutes({ domain, config, layout, userAuth }) {router.get('/:username', async (req, res, next) => {try {if (RESERVED.has(req.params.username.toLowerCase())) return notFound(req, res);-- const c = creators.byUsername(req.params.username);++ const c = await creators.byUsername(req.params.username);if (!c || c.status !== 'active') return notFound(req, res);if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);-- const list = plans.list({ creatorId: c.id }).map((p) => plans.present(p));++ const list = (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p))));const isOwner = !!(req.viewer && req.viewer.subject === c.subject);const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;const name = c.display_name || c.username;@@@ -239,21 +239,21 @@ function createWebRoutes({ domain, config, layout, userAuth }) {});} catch (e) { next(e); }});-- router.get('/:username/plans/:slug', (req, res) => {-- const c = creators.byUsername(req.params.username);++ router.get('/:username/plans/:slug', async (req, res) => {++ const c = await creators.byUsername(req.params.username);if (!c || c.status !== 'active') return notFound(req, res);-- const plan = plans.list({ creatorId: c.id, includeArchived: true }).find((p) => p.slug === req.params.slug);++ const plan = (await plans.list({ creatorId: c.id, includeArchived: true })).find((p) => p.slug === req.params.slug);if (!plan) return notFound(req, res);-- const versions = plans.versions(plan.id).filter((v) => v.published_at).map(plans.presentVersion);++ const versions = (await Promise.all((await plans.versions(plan.id)).filter((v) => v.published_at).map(plans.presentVersion)));return send(res, 200, {title: `Terms history · ${c.display_name || c.username}`, canonicalPath: `/${c.username}/plans/${plan.slug}`, viewer: req.viewer, robots: 'noindex,follow',body: pages.planHistory({ creator: creators.present(c), plan, versions }),});});post('/:username/join', async (req, res, to) => {-- const c = creators.byUsername(req.params.username);++ const c = await creators.byUsername(req.params.username);if (!c) throw new VipError(404, 'vip.creator_not_found', 'no such creator');-- const plan = plans.byId(String(req.body.plan_id || ''));++ const plan = await plans.byId(String(req.body.plan_id || ''));if (!plan || plan.creator_id !== c.id) throw new VipError(404, 'vip.plan_not_found', 'that plan is not offered here');const out = await checkout.start({member: req.viewer.subject, planId: req.body.plan_id, provider: req.body.provider, autoRenew: bool(req.body.auto_renew),@@@ -70,9 +70,9 @@ const { test, run } = harness('api');test('when Billing settles the handed-off checkout, the membership is filed under the version chosen then', async () => {await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['Badge in chat', 'Monthly stream'] } }); // v2 published meanwhileawait t.deliverAll(t.billing.pay(buyer.subject, creator.subject, { provider: 'stripe' }).events);-- const m = t.domain.memberships.get(buyer.subject, t.domain.creators.bySubject(creator.subject).id);++ const m = await t.domain.memberships.get(buyer.subject, (await t.domain.creators.bySubject(creator.subject)).id);assert.strictEqual(m.origin, 'checkout');-- assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 1);++ assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 1);const again = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });assert.strictEqual(again.status, 409);assert.strictEqual(again.json.code, 'vip.already_member');@@@ -90,10 +90,10 @@ const { test, run } = harness('api');assert.strictEqual(e.json.status, 'active');assert.strictEqual(e.json.membership.plan_version.version, 2);// Billing's own event for the same grant arrives later and changes nothing.-- const before = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === payer.subject).length;++ const before = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === payer.subject).length;const out = await t.deliverAll(t.billing.state.lastEvents);assert.ok(['unchanged', 'stale_ignored'].includes(out[1].json.outcome), out[1].json.outcome);-- assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === payer.subject).length, before);++ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === payer.subject).length, before);assert.strictEqual(before, 1);});@@@ -116,7 +116,7 @@ const { test, run } = harness('api');});test('cancel goes through Billing (member only) and keeps the paid period', async () => {-- const cid = t.domain.creators.bySubject(creator.subject).id;++ const cid = (await t.domain.creators.bySubject(creator.subject)).id;const svc = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { cap: ['vip.membership.checkout'] });assert.strictEqual(svc.status, 403);const r = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { user: buyer });@@@ -213,7 +213,7 @@ const { test, run } = harness('api');const other = t.network.newUser('tess', { role: 'streamer' });const steal = await t.page(`/dashboard/plans/${plan.id}`, { user: other, method: 'POST', form: { _csrf: t.csrf((await t.page('/dashboard', { user: other })).text), name: 'mine now' } });assert.match(steal.location, /error=/);-- assert.strictEqual(t.domain.plans.latestVersion(plan.id).version, 3);++ assert.strictEqual((await t.domain.plans.latestVersion(plan.id)).version, 3);const net = await t.page('/dashboard?as=network', { user: creator });assert.strictEqual(net.status, 403);const anon = await t.page('/dashboard');@@@ -243,5 +243,5 @@ const { test, run } = harness('api');} finally { await t2.close(); }});-- await run().finally(() => t.close());++ await run().finally(async () => await t.close());})();@@@ -27,7 +27,7 @@ const TTL = 30_000;await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } });const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:community', cap: ['vip.entitlement.check', 'vip.resource.policy.evaluate'] })}` }) };let vipDown = false;-- const fetchVia = (url, init) => (vipDown ? Promise.reject(new Error('connect ECONNREFUSED')) : fetch(url, init));++ const fetchVia = async (url, init) => (vipDown ? Promise.reject(new Error('connect ECONNREFUSED')) : await fetch(url, init));const vip = createVipClient({ baseUrl: t.base, tokenClient, fetch: fetchVia });const newCache = () => createVipCache({ vip, ttlMs: TTL, denyTtlMs: 10_000, unavailableTtlMs: 2_000, now: t.clock.now });const space = { service: 'community', type: 'space', id: 'crew' };@@@ -38,7 +38,7 @@ const TTL = 30_000;assert.strictEqual(e.active, d.allow, `entitlement and evaluate agree (${e.status} / ${d.reason})`);return d.allow;};-- const lastChanged = (m) => t.outboxEvents('vip.membership.changed').filter((e) => e.payload.member.id === m.subject).pop();++ const lastChanged = async (m) => (await t.outboxEvents('vip.membership.changed')).filter((e) => e.payload.member.id === m.subject).pop();test('VIP emits vip.membership.changed; a cache given the event stops granting at once', async () => {const m = t.network.newUser('m1');@@@ -46,7 +46,7 @@ const TTL = 30_000;await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);assert.strictEqual(await grants(cache, m), true);await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);-- const ev = lastChanged(m);++ const ev = await lastChanged(m);assert.strictEqual(ev.payload.active, false);assert.strictEqual(await grants(cache, m), true, 'still the cached yes (the event has not reached the product)');assert.strictEqual(cache.handleEvent(ev), true);@@@ -149,5 +149,5 @@ const TTL = 30_000;assert.deepStrictEqual(cache.bounds, { grantMs: TTL, denyMs: 10_000, unavailableMs: 2_000 });});-- await run().finally(() => t.close());++ await run().finally(async () => await t.close());})();@@@ -33,7 +33,7 @@ const MIN = 60_000;assert.strictEqual(e.source, 'projection');assert.strictEqual(e.membership.plan_version.version, 1);assert.strictEqual(t.billing.entitlementCalls(), calls);-- const changed = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);++ const changed = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject);assert.strictEqual(changed.length, 1);assert.strictEqual(changed[0].payload.active, true);assert.strictEqual(changed[0].payload.reason, 'granted');@@@ -45,7 +45,7 @@ const MIN = 60_000;const ent = t.billing.pay(m.subject, creator.subject).events[1];assert.strictEqual((await t.deliver(ent)).json.duplicate, false);assert.strictEqual((await t.deliver(ent)).json.duplicate, true);-- assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject).length, 1);++ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject).length, 1);});test('deliveries must be signed (v2, inside the replay window); only Billing is believed', async () => {@@@ -56,7 +56,7 @@ const MIN = 60_000;assert.strictEqual((await t.deliver(ent, { now: Date.now() - 301000 })).status, 401, 'stale v2 (outside the 300 s window): refused');const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');-- assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);++ assert.strictEqual(await t.domain.entitlements.getRow(m.subject, creator.subject), null);const raw = JSON.stringify({ event: ent, seq: 1 });const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: signDeliveryHeaders(raw + ' ', t.EVENTS_SECRET) });assert.strictEqual(res.status, 401);@@@ -71,7 +71,7 @@ const MIN = 60_000;let e = await check(m);assert.strictEqual(e.active, true);assert.strictEqual(e.cancel_at_period_end, true);-- const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);++ const ev = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject);assert.strictEqual(ev[ev.length - 1].payload.reason, 'cancel_scheduled');t.clock.advance(26 * DAY);await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);@@@ -106,7 +106,7 @@ const MIN = 60_000;assert.strictEqual(e.active, true);assert.strictEqual(e.stale, false);assert.strictEqual(Date.parse(e.expires_at), Date.parse(t.billing.entitlement(m.subject, creator.subject).expires_at));-- const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);++ const ev = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject);assert.strictEqual(ev[ev.length - 1].payload.reason, 'renewed');t.clock.advance(-(30 * DAY + 5 * MIN));});@@@ -133,7 +133,7 @@ const MIN = 60_000;assert.deepStrictEqual(out.map((o) => o.json.outcome), ['doubt', 'changed']);const e = await check(m, 'projection');assert.strictEqual(e.active, false);-- const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);++ const ev = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject);assert.strictEqual(ev[ev.length - 1].payload.reason, 'refund');assert.strictEqual(ev[ev.length - 1].payload.active, false);});@@@ -155,7 +155,7 @@ const MIN = 60_000;const m = t.network.newUser('m10');await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);t.billing.refund(m.subject, creator.subject); // nothing delivered-- const row = t.domain.entitlements.getRow(m.subject, creator.subject);++ const row = await t.domain.entitlements.getRow(m.subject, creator.subject);assert.strictEqual(row.valid_until - row.synced_at, t.config.projection.maxAgeMs);t.clock.advance(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1);assert.strictEqual((await check(m, 'projection')).active, false);@@@ -241,7 +241,7 @@ const MIN = 60_000;t.clock.advance(t.config.projection.maxAgeMs - MIN);const out = await t.domain.entitlements.refreshDue();assert.ok(out.checked >= 1 && out.ok >= 1);-- const row = t.domain.entitlements.getRow(m.subject, creator.subject);++ const row = await t.domain.entitlements.getRow(m.subject, creator.subject);assert.strictEqual(row.source, 'billing_check');assert.ok(row.valid_until > t.clock.now() + 10 * MIN);});@@@ -260,5 +260,5 @@ const MIN = 60_000;assert.strictEqual(byName.status, 200, byName.text);});-- await run().finally(() => t.close());++ await run().finally(async () => await t.close());})();@@@ -49,7 +49,9 @@ async function boot(opts = {}) {const config = loadConfig(env);const logs = [];const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };-- const app = createApp({ config, now: clock.now, log });++ // One database per boot (PGlite, or VIP_TEST_STORE=pg: the containers), dropped when the boot closes.++ const testdb = opts.testdb || await require('./db').testDb();++ const app = await createApp({ config, db: testdb.db, now: clock.now, log });const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;const domain = app.locals.domain;@@@ -87,18 +89,18 @@ async function boot(opts = {}) {}const csrf = (html) => { const m = /name="_csrf" value="([^"]+)"/.exec(html); return m ? m[1] : null; };-- const outboxEvents = (type) => domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type);++ const outboxEvents = async (type) => (await domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type);async function close() {await new Promise((r) => server.close(r));app.locals.outbox.stop();await billing.close();await network.close();-- try { domain.db.close(); } catch { /* */ }++ if (!opts.testdb) await testdb.close(); // a caller that handed its own database closes itfs.rmSync(dir, { recursive: true, force: true });}-- return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, csrf, outboxEvents, close, EVENTS_SECRET };++ return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, csrf, outboxEvents, close, testdb, EVENTS_SECRET };}/** Tiny test harness: sequential async tests, a summary, exit code. */nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * VIP_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.VIP_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'vip', max });++++module.exports = { testDb, pgAvailable };@@@ -53,15 +53,15 @@ function liveSnapshot() {const own = (await t.call('POST', '/api/v1/plans', { user: selfmade, body: { name: 'My own plan', publish: true } })).json.plan;const doImport = (opts = {}) => importLive(t.domain, { live, billingSource: billingApiSource(t.domain.billing), resolveLiveUsers: identity.resolveLiveUsers, log: null, ...opts });-- const snapshotCounts = () => Object.fromEntries(['vip_creators', 'vip_plans', 'vip_plan_versions', 'vip_perks', 'vip_memberships', 'vip_migration_maps', 'vip_entitlement_projection', 'event_outbox']-- .map((x) => [x, t.domain.db.prepare(`SELECT COUNT(*) AS n FROM ${x}`).get().n]));++ const snapshotCounts = async () => Object.fromEntries((await Promise.all(['vip_creators', 'vip_plans', 'vip_plan_versions', 'vip_perks', 'vip_memberships', 'vip_migration_maps', 'vip_entitlement_projection', 'event_outbox']++ .map(async (x) => [x, (await t.domain.db.prepare(`SELECT COUNT(*) AS n FROM ${x}`).get()).n]))));test('a dry run reports everything and keeps nothing', async () => {-- const before = snapshotCounts();++ const before = await snapshotCounts();const r = await doImport({ dryRun: true });assert.strictEqual(r.dry_run, true);assert.strictEqual(r.counts.plans.created, 2);-- assert.deepStrictEqual(snapshotCounts(), before);++ assert.deepStrictEqual(await snapshotCounts(), before);});let first;@@@ -76,38 +76,38 @@ function liveSnapshot() {assert.deepStrictEqual(first.counts.live_subscriptions, { linked: 3, held: 0, unchanged: 0 });assert.ok(first.holds.some((h) => h.live_user === 4));-- const perk = t.domain.perks.byKey('network', 'subscriber-badge');-- assert.deepStrictEqual(t.domain.perks.bindingsOf(perk.id).map((b) => `${b.product}:${b.binding}`).sort(), ['chat:badge', 'live:ai_context', 'live:chat_badge', 'live:powerchat_overlay']);-- const jog = t.domain.creators.bySubject(S[1]);++ const perk = await t.domain.perks.byKey('network', 'subscriber-badge');++ assert.deepStrictEqual((await t.domain.perks.bindingsOf(perk.id)).map((b) => `${b.product}:${b.binding}`).sort(), ['chat:badge', 'live:ai_context', 'live:chat_badge', 'live:powerchat_overlay']);++ const jog = await t.domain.creators.bySubject(S[1]);assert.strictEqual(jog.username, 'japaneseoldguy');assert.strictEqual(jog.display_name, 'Japanese Old Guy');-- const plan = t.domain.plans.purchasable(jog.id);++ const plan = await t.domain.plans.purchasable(jog.id);assert.strictEqual(plan.slug, 'channel-subscription');assert.strictEqual(plan.latest_version, 1);-- assert.deepStrictEqual(t.domain.plans.versionPerks(plan.current_version_id).map((p) => p.perk_key), ['subscriber-badge']);-- const terms = JSON.parse(t.domain.plans.version(plan.current_version_id).terms);++ assert.deepStrictEqual((await t.domain.plans.versionPerks(plan.current_version_id)).map((p) => p.perk_key), ['subscriber-badge']);++ const terms = JSON.parse((await t.domain.plans.version(plan.current_version_id)).terms);assert.strictEqual(terms.price, 'set and charged by OpenVibe.Billing');assert.doesNotMatch(JSON.stringify(terms), /\bfree\b|\$0|no ads/i);-- const m = t.domain.memberships.get(S[2], jog.id);++ const m = await t.domain.memberships.get(S[2], jog.id);assert.strictEqual(m.origin, 'import');assert.strictEqual(m.plan_version_id, plan.current_version_id);-- assert.strictEqual(t.domain.memberships.get(S[3], jog.id), null, 'an expired Billing subscription is not a membership');-- assert.strictEqual(t.domain.plans.purchasable(t.domain.creators.bySubject(S[6]).id).id, own.id, 'the creator\'s own plan is linked, not replaced');++ assert.strictEqual(await t.domain.memberships.get(S[3], jog.id), null, 'an expired Billing subscription is not a membership');++ assert.strictEqual((await t.domain.plans.purchasable((await t.domain.creators.bySubject(S[6])).id)).id, own.id, 'the creator\'s own plan is linked, not replaced');const e = await t.call('GET', `/api/v1/entitlements/check?creator=${S[1]}&mode=projection`, { user: { subject: S[2], username: 'viewer1' } });assert.strictEqual(e.json.status, 'active');-- const maps = t.domain.db.prepare("SELECT status, COUNT(*) AS n FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' GROUP BY status").all();++ const maps = await t.domain.db.prepare("SELECT status, COUNT(*) AS n FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' GROUP BY status").all();assert.deepStrictEqual(maps, [{ status: 'linked', n: 3 }]);-- const expiredRow = t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '2'").get();++ const expiredRow = await t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '2'").get();assert.strictEqual(expiredRow.target_type, 'billing_subscription');-- assert.strictEqual(t.outboxEvents('vip.plan.published').length, 3);++ assert.strictEqual((await t.outboxEvents('vip.plan.published')).length, 3);});test('a second run changes nothing', async () => {-- const before = snapshotCounts();++ const before = await snapshotCounts();const again = await doImport();-- assert.deepStrictEqual(snapshotCounts(), before);++ assert.deepStrictEqual(await snapshotCounts(), before);assert.deepStrictEqual(again.counts.plans, { created: 0, linked: 0, unchanged: 3 });assert.deepStrictEqual(again.counts.memberships, { imported: 0, unchanged: 3, excluded: 0 });assert.deepStrictEqual(again.counts.live_subscriptions, { linked: 0, held: 0, unchanged: 3 });@@@ -115,12 +115,12 @@ function liveSnapshot() {});test('a creator\'s edits after the import are never overwritten', async () => {-- const jog = t.domain.creators.bySubject(S[1]);-- const plan = t.domain.plans.purchasable(jog.id);-- t.domain.plans.update(plan.id, { name: 'Japanese Old Guy club', actor: S[1] });++ const jog = await t.domain.creators.bySubject(S[1]);++ const plan = await t.domain.plans.purchasable(jog.id);++ await t.domain.plans.update(plan.id, { name: 'Japanese Old Guy club', actor: S[1] });await doImport();-- assert.strictEqual(t.domain.plans.latestVersion(plan.id).version, 2);-- assert.strictEqual(t.domain.plans.latestVersion(plan.id).name, 'Japanese Old Guy club');++ assert.strictEqual((await t.domain.plans.latestVersion(plan.id)).version, 2);++ assert.strictEqual((await t.domain.plans.latestVersion(plan.id)).name, 'Japanese Old Guy club');});test('a held creator is released once the Network knows them', async () => {@@@ -129,7 +129,7 @@ function liveSnapshot() {assert.strictEqual(r.counts.creators.released, 1);assert.strictEqual(r.counts.creators.held, 0);assert.strictEqual(r.counts.plans.created, 1);-- assert.strictEqual(t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'users' AND source_id = '4'").get().status, 'imported');++ assert.strictEqual((await t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'users' AND source_id = '4'").get()).status, 'imported');});test('Billing unreachable: the run says so, holds what it could not reconcile and never invents memberships', async () => {@@@ -143,15 +143,15 @@ function liveSnapshot() {try { r = await doImport(); } finally { t.billing.state.down = false; }assert.strictEqual(r.ok, false);assert.ok(r.billing_errors.length >= 1);-- assert.strictEqual(t.domain.memberships.get(S[2], t.domain.creators.bySubject(x).id), null);-- const row = t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get();++ assert.strictEqual(await t.domain.memberships.get(S[2], (await t.domain.creators.bySubject(x)).id), null);++ const row = await t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get();assert.strictEqual(row.status, 'held');assert.match(row.reason, /Billing was not reachable/);const r2 = await doImport();assert.strictEqual(r2.ok, true);-- assert.strictEqual(t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get().status, 'linked');-- assert.ok(t.domain.memberships.get(S[2], t.domain.creators.bySubject(x).id));++ assert.strictEqual((await t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get()).status, 'linked');++ assert.ok(await t.domain.memberships.get(S[2], (await t.domain.creators.bySubject(x)).id));});-- await run().finally(() => { live.close(); fs.rmSync(snap.dir, { recursive: true, force: true }); return t.close(); });++ await run().finally(async () => { live.close(); fs.rmSync(snap.dir, { recursive: true, force: true }); return await t.close(); });})();@@@ -30,7 +30,7 @@ const { test, run } = harness('plans');assert.strictEqual(plan.billing_kind, 'channel_subscription');assert.strictEqual(plan.current_version.version, 1);assert.deepStrictEqual(plan.current_version.perks.map((p) => p.key), ['emotes', 'supporter-role']);-- const ev = t.outboxEvents('vip.plan.published');++ const ev = await t.outboxEvents('vip.plan.published');assert.strictEqual(ev.length, 1);assert.strictEqual(ev[0].subject.id, plan.id);assert.strictEqual(ev[0].payload.version, 1);@@@ -39,29 +39,29 @@ const { test, run } = harness('plans');test('a member who joins under v1 is filed under v1', async () => {await t.deliverAll(t.billing.pay(early.subject, creator.subject).events);-- const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);++ const m = await t.domain.memberships.get(early.subject, (await t.domain.creators.bySubject(creator.subject)).id);assert.strictEqual(m.plan_version_id, plan.current_version.id);});test('editing a published plan creates v2, publishes it, and never touches v1', async () => {-- const before = t.domain.plans.version(plan.current_version.id);++ const before = await t.domain.plans.version(plan.current_version.id);const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'Inner circle', perks: ['supporter-role'], benefits: ['A role'], change_note: 'emotes moved to a separate plan' } });assert.strictEqual(res.status, 200, res.text);assert.strictEqual(res.json.version.version, 2);assert.strictEqual(res.json.plan.current_version.version, 2);-- const after = t.domain.plans.version(plan.current_version.id);++ const after = await t.domain.plans.version(plan.current_version.id);assert.deepStrictEqual(after, before, 'v1 row unchanged');const versions = await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null });assert.deepStrictEqual(versions.json.versions.map((v) => v.version), [2, 1]);assert.deepStrictEqual(versions.json.versions[1].perks.map((p) => p.key), ['emotes', 'supporter-role']);-- assert.strictEqual(t.outboxEvents('vip.plan.published').length, 2);++ assert.strictEqual((await t.outboxEvents('vip.plan.published')).length, 2);});-- test('the database refuses to rewrite or delete a version', () => {-- assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_versions SET name = 'rewritten' WHERE id = ?").run(plan.current_version.id), /immutable/);-- assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_versions SET terms = '{}' WHERE id = ?").run(plan.current_version.id), /immutable/);-- assert.throws(() => t.domain.db.prepare('DELETE FROM vip_plan_versions WHERE id = ?').run(plan.current_version.id), /immutable/);-- assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_perks SET perk_name = 'x' WHERE plan_version_id = ?").run(plan.current_version.id), /immutable/);++ test('the database refuses to rewrite or delete a version', async () => {++ await assert.rejects(t.domain.db.prepare("UPDATE vip_plan_versions SET name = 'rewritten' WHERE id = ?").run(plan.current_version.id), /immutable/);++ await assert.rejects(t.domain.db.prepare("UPDATE vip_plan_versions SET terms = '{}' WHERE id = ?").run(plan.current_version.id), /immutable/);++ await assert.rejects(t.domain.db.prepare('DELETE FROM vip_plan_versions WHERE id = ?').run(plan.current_version.id), /immutable/);++ await assert.rejects(t.domain.db.prepare("UPDATE vip_plan_perks SET perk_name = 'x' WHERE plan_version_id = ?").run(plan.current_version.id), /immutable/);});test('an edit that changes nothing creates no version', async () => {@@@ -71,7 +71,7 @@ const { test, run } = harness('plans');});test('renaming a perk does not change published terms', async () => {-- const perk = t.domain.perks.byKey(t.domain.creators.bySubject(creator.subject).id, 'emotes');++ const perk = await t.domain.perks.byKey((await t.domain.creators.bySubject(creator.subject)).id, 'emotes');await t.call('PATCH', `/api/v1/perks/${perk.id}`, { user: creator, body: { name: 'Renamed emotes' } });const v1 = (await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null })).json.versions[1];assert.strictEqual(v1.perks.find((p) => p.key === 'emotes').name, 'Members emotes');@@@ -79,13 +79,13 @@ const { test, run } = harness('plans');test('old members keep old terms; new members get the new version', async () => {await t.deliverAll(t.billing.pay(late.subject, creator.subject).events);-- const cid = t.domain.creators.bySubject(creator.subject).id;-- const mEarly = t.domain.memberships.get(early.subject, cid);-- const mLate = t.domain.memberships.get(late.subject, cid);-- assert.strictEqual(t.domain.plans.version(mEarly.plan_version_id).version, 1);-- assert.strictEqual(t.domain.plans.version(mLate.plan_version_id).version, 2);-- assert.deepStrictEqual(t.domain.memberships.perkKeys(mEarly), ['emotes', 'supporter-role']);-- assert.deepStrictEqual(t.domain.memberships.perkKeys(mLate), ['supporter-role']);++ const cid = (await t.domain.creators.bySubject(creator.subject)).id;++ const mEarly = await t.domain.memberships.get(early.subject, cid);++ const mLate = await t.domain.memberships.get(late.subject, cid);++ assert.strictEqual((await t.domain.plans.version(mEarly.plan_version_id)).version, 1);++ assert.strictEqual((await t.domain.plans.version(mLate.plan_version_id)).version, 2);++ assert.deepStrictEqual(await t.domain.memberships.perkKeys(mEarly), ['emotes', 'supporter-role']);++ assert.deepStrictEqual(await t.domain.memberships.perkKeys(mLate), ['supporter-role']);const status = await t.call('GET', `/api/v1/memberships/${early.subject}`, { user: early });assert.strictEqual(status.status, 200);assert.strictEqual(status.json.memberships[0].plan_version.version, 1);@@@ -95,8 +95,8 @@ const { test, run } = harness('plans');test('a renewal keeps the version the membership was bought under', async () => {t.clock.advance(29 * DAY);await t.deliverAll(t.billing.pay(early.subject, creator.subject).events); // reason: renewed-- const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);-- assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 1);++ const m = await t.domain.memberships.get(early.subject, (await t.domain.creators.bySubject(creator.subject)).id);++ assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 1);});test('a new purchase after the membership lapsed takes the current version', async () => {@@@ -106,8 +106,8 @@ const { test, run } = harness('plans');await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['A role', 'Monthly Q&A'], change_note: 'added Q&A' } });await t.deliverAll(t.billing.pay(late.subject, creator.subject).events); // reason: granted-- const m = t.domain.memberships.get(late.subject, t.domain.creators.bySubject(creator.subject).id);-- assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 3);++ const m = await t.domain.memberships.get(late.subject, (await t.domain.creators.bySubject(creator.subject)).id);++ assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 3);});test('Billing sells one channel subscription per creator: a second published plan is refused', async () => {@@@ -123,7 +123,7 @@ const { test, run } = harness('plans');});test('archived plans keep their members on their terms and cannot be edited', async () => {-- const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);++ const m = await t.domain.memberships.get(early.subject, (await t.domain.creators.bySubject(creator.subject)).id);assert.strictEqual(m.plan_id, plan.id);const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'x' } });assert.strictEqual(res.status, 409);@@@ -156,12 +156,12 @@ const { test, run } = harness('plans');});test('drafts are hidden from the public', async () => {-- const drafts = t.domain.plans.list({ creatorId: t.domain.creators.bySubject(creator.subject).id, includeDrafts: true }).filter((p) => p.status === 'draft');++ const drafts = (await t.domain.plans.list({ creatorId: (await t.domain.creators.bySubject(creator.subject)).id, includeDrafts: true })).filter((p) => p.status === 'draft');const res = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { token: null });assert.strictEqual(res.status, 404);const own = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { user: creator });assert.strictEqual(own.status, 200);});-- await run().finally(() => t.close());++ await run().finally(async () => await t.close());})();@@@ -230,5 +230,5 @@ const { test, run } = harness('policies');assert.strictEqual((await garbage.evaluate({ subject: member.subject, resource: res })).allow, false, 'only a literal true allows');});-- await run().finally(() => t.close());++ await run().finally(async () => await t.close());})();
@@ -6,6 +6,14 @@ NODE_ENV=development
@@ -6,6 +6,14 @@ NODE_ENV=development
PORT=4620
PORT=4620
HOST=127.0.0.1
HOST=127.0.0.1
BASE_URL=http://localhost:4620
BASE_URL=http://localhost:4620
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh vip: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=
+# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
VIP_DB_PATH=./data/vip.db
VIP_DB_PATH=./data/vip.db
# TRUST_PROXY=1
# TRUST_PROXY=1
@@ -9,6 +9,9 @@ jobs:
@@ -9,6 +9,9 @@ jobs:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
contracts-service: vip
contracts-service: vip
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
syntax-dirs: server scripts client
syntax-dirs: server scripts client
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
@@ -29,7 +29,7 @@ The nine authority tables (VIP is their only writer):
@@ -29,7 +29,7 @@ The nine authority tables (VIP is their only writer):
|---|---|
|---|---|
| `vip_creators` | a creator (a Network `usr_` subject, with a cached handle for `/:username`) or the network itself |
| `vip_creators` | a creator (a Network `usr_` subject, with a cached handle for `/:username`) or the network itself |
| `vip_plans` | a plan: status (draft/published/archived), the Billing product it is sold as, its current version |
| `vip_plans` | a plan: status (draft/published/archived), the Billing product it is sold as, its current version |
-| `vip_plan_versions` | **immutable** plan terms, one row per edit (SQLite triggers refuse UPDATE/DELETE except the one-time publish stamp) |
+| `vip_plan_versions` | **immutable** plan terms, one row per edit (database triggers refuse UPDATE/DELETE except the one-time publish stamp) |
| `vip_perks` | perk definitions (creator or network scope): badge, emote, gated content, room, role, other |
| `vip_perks` | perk definitions (creator or network scope): badge, emote, gated content, room, role, other |
| `vip_plan_perks` | **immutable**: which perks a plan *version* includes, with the key and name it was published with |
| `vip_plan_perks` | **immutable**: which perks a plan *version* includes, with the key and name it was published with |
| `vip_product_bindings` | how a perk shows up in a product (`live chat_badge`, `chat badge`, `blog gated_post`, …) |
| `vip_product_bindings` | how a perk shows up in a product (`live chat_badge`, `chat badge`, `blog gated_post`, …) |
@@ -60,8 +60,9 @@ home that is not a cache.
@@ -60,8 +60,9 @@ home that is not a cache.
- **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages, identity
- **OpenVibe.Network** — RS256 service and user tokens (JWKS), OAuth sign-in for the pages, identity
resolve (importer only).
resolve (importer only).
- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.
- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.
-- **openvibe-contracts** v0.49.0, **openvibe-sdk** v0.12.0 (outbox, inbox, delivery signatures, per-actor
- limits), **openvibe-shared** v1.25.0 (chrome, legal pages, release manifest and update metrics, metrics,
+- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
+- **openvibe-contracts** v0.76.0, **openvibe-sdk** v0.20.1 (outbox, inbox, delivery signatures, per-actor
+ limits), **openvibe-shared** v1.27.0 (Frame, legal pages, release manifest and update metrics, metrics,
readiness).
readiness).
- Consumers, through the client below: Chat (the member badge), Community (members-only spaces and
- Consumers, through the client below: Chat (the member badge), Community (members-only spaces and
threads), Blog (members-only posts) and Wiki (VIP spaces and pages); Live is not wired yet.
threads), Blog (members-only posts) and Wiki (VIP spaces and pages); Live is not wired yet.
@@ -119,7 +120,7 @@ The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) is no
@@ -119,7 +120,7 @@ The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) is no
### The entitlement projection (Billing is the truth)
### The entitlement projection (Billing is the truth)
`vip_entitlement_projection` is a cache of Billing's entitlements, fed by Billing's events
`vip_entitlement_projection` is a cache of Billing's entitlements, fed by Billing's events
-(signed Events webhook + openvibe-sdk `createInbox`, exactly once) and by direct checks.
+(signed Events webhook + openvibe-sdk `createPgInbox`, exactly once) and by direct checks.
- `valid_until = min(paid period end, last word from Billing + VIP_PROJECTION_MAX_AGE_MS)`; a "no"
- `valid_until = min(paid period end, last word from Billing + VIP_PROJECTION_MAX_AGE_MS)`; a "no"
is kept for the max age.
is kept for the max age.
@@ -386,7 +387,11 @@ on the host (Billing is in shadow and has sent VIP no events). The Live import r
@@ -386,7 +387,11 @@ on the host (Billing is in shadow and has sent VIP no events). The Live import r
Production deploys with `sudo ovhost deploy vip` on the host (strategy `git-checkout`: fetch,
Production deploys with `sudo ovhost deploy vip` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.vip`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.vip`, install on a lockfile change, restart, wait for `/api/ready`).
-The unit is `openvibe-vip.service` on `127.0.0.1:4620`, the env file `/etc/openvibe/vip.env`. State lives in
+The unit is `openvibe-vip.service` on `127.0.0.1:4620`, the env file `/etc/openvibe/vip.env`. The database is
+`ov_vip` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh vip` writes its settings); the
+release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
+`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old
+`/var/lib/openvibe-vip/vip.db` stays read-only for 7 days as the rollback. State lives in
`/var/lib/openvibe-vip`. The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) waits
`/var/lib/openvibe-vip`. The vhost [deploy/nginx/openvibe.vip.conf](deploy/nginx/openvibe.vip.conf) waits
for the launch: `openvibe.vip` serves the Sites placeholder.
for the launch: `openvibe.vip` serves the Sites placeholder.
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"deployed": true,
"deployed": true,
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 loopback only; https://openvibe.vip still serves the OpenVibe.Sites placeholder. Principal vip; three Events subscriptions for Billing's events (secret rotated 2026-09-24); checkout closed (VIP_CHECKOUT_PROVIDERS empty). On 2026-09-24 production held the Live import: 44 creators, 43 plans (one per creator), 0 memberships while Billing runs in shadow.",
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 loopback only; https://openvibe.vip still serves the OpenVibe.Sites placeholder. Principal vip; three Events subscriptions for Billing's events (secret rotated 2026-09-24); checkout closed (VIP_CHECKOUT_PROVIDERS empty). On 2026-09-24 production held the Live import: 44 creators, 43 plans (one per creator), 0 memberships while Billing runs in shadow.",
- "contracts": "openvibe-contracts v0.49.0",
+ "contracts": "openvibe-contracts v0.76.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"roadmap": "Wave 10",
"roadmap": "Wave 10",
"works": [
"works": [
@@ -41,5 +41,6 @@
@@ -41,5 +41,6 @@
"Live subscription import",
"Live subscription import",
"SDK outbox events vip.plan.published and vip.membership.changed",
"SDK outbox events vip.plan.published and vip.membership.changed",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
- ]
+ ],
+ "runtime": ""
}
}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
Description=OpenVibe.VIP — memberships: plans, versioned terms, perks and gated-resource policy
Description=OpenVibe.VIP — memberships: plans, versioned terms, perks and gated-resource policy
# Network signs the tokens VIP verifies (its key is fetched at boot and retried); Billing holds
# Network signs the tokens VIP verifies (its key is fetched at boot and retried); Billing holds
# the entitlement truth; Events relays the outbox when EVENTS_URL is set. VIP starts without any.
# the entitlement truth; Events relays the outbox when EVENTS_URL is set. VIP starts without any.
-After=network.target
+After=network.target postgresql.service pgbouncer.service valkey-server.service
Wants=openvibe-network.service openvibe-billing.service
Wants=openvibe-network.service openvibe-billing.service
[Service]
[Service]
new file mode 100644
new file mode 100644
@@ -0,0 +1,237 @@
@@ -0,0 +1,237 @@
+-- phase: expand
+-- OpenVibe.VIP on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
+-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
+-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.
+
+CREATE TABLE settings (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY CHECK (id = 1),
+ created_at text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE vip_creators (
+ id text COLLATE "C" PRIMARY KEY,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('creator', 'network')),
+ subject text COLLATE "C" UNIQUE,
+ username text COLLATE "C",
+ username_lc text COLLATE "C" UNIQUE,
+ display_name text COLLATE "C",
+ bio text COLLATE "C",
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),
+ origin text COLLATE "C" NOT NULL DEFAULT 'self' CHECK (origin IN ('self', 'service', 'import', 'staff', 'system')),
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ CHECK ((kind = 'network' AND subject IS NULL) OR (kind = 'creator' AND subject IS NOT NULL))
+);
+
+CREATE TABLE vip_plans (
+ id text COLLATE "C" PRIMARY KEY,
+ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),
+ slug text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
+ billing_kind text COLLATE "C" CHECK (billing_kind IS NULL OR billing_kind IN ('channel_subscription')),
+ current_version_id text COLLATE "C",
+ latest_version bigint NOT NULL DEFAULT 0,
+ sort bigint NOT NULL DEFAULT 0,
+ created_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ archived_at text COLLATE "C",
+ UNIQUE (creator_id, slug)
+);
+-- Billing sells one channel subscription per (subscriber, creator) today, so a creator can have at
+-- most one published plan bound to it.
+CREATE UNIQUE INDEX idx_vip_plans_one_billing_product
+ ON vip_plans (creator_id, billing_kind) WHERE status = 'published' AND billing_kind IS NOT NULL;
+
+CREATE TABLE vip_plan_versions (
+ id text COLLATE "C" PRIMARY KEY,
+ plan_id text COLLATE "C" NOT NULL REFERENCES vip_plans(id),
+ version bigint NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ benefits text COLLATE "C" NOT NULL DEFAULT '[]',
+ terms text COLLATE "C" NOT NULL,
+ change_note text COLLATE "C",
+ created_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ published_at text COLLATE "C",
+ UNIQUE (plan_id, version)
+);
+CREATE FUNCTION vip_plan_versions_no_rewrite() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'vip_plan_versions are immutable: edit a plan by creating a new version'; END $$;
+CREATE TRIGGER vip_plan_versions_no_rewrite BEFORE UPDATE ON vip_plan_versions FOR EACH ROW WHEN (NEW.id IS DISTINCT FROM OLD.id OR NEW.plan_id IS DISTINCT FROM OLD.plan_id OR NEW.version IS DISTINCT FROM OLD.version OR NEW.name IS DISTINCT FROM OLD.name OR NEW.description IS DISTINCT FROM OLD.description OR NEW.benefits IS DISTINCT FROM OLD.benefits OR NEW.terms IS DISTINCT FROM OLD.terms OR NEW.change_note IS DISTINCT FROM OLD.change_note OR NEW.created_by IS DISTINCT FROM OLD.created_by OR NEW.created_at IS DISTINCT FROM OLD.created_at OR (OLD.published_at IS NOT NULL AND NEW.published_at IS DISTINCT FROM OLD.published_at)) EXECUTE FUNCTION vip_plan_versions_no_rewrite();
+CREATE FUNCTION vip_plan_versions_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'vip_plan_versions are immutable: edit a plan by creating a new version'; END $$;
+CREATE TRIGGER vip_plan_versions_no_delete BEFORE DELETE ON vip_plan_versions FOR EACH ROW EXECUTE FUNCTION vip_plan_versions_no_delete();
+
+CREATE TABLE vip_perks (
+ id text COLLATE "C" PRIMARY KEY,
+ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),
+ key text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ kind text COLLATE "C" NOT NULL DEFAULT 'other' CHECK (kind IN ('badge', 'emote', 'gated_content', 'room', 'role', 'other')),
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
+ created_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ UNIQUE (creator_id, key)
+);
+
+CREATE TABLE vip_plan_perks (
+ plan_version_id text COLLATE "C" NOT NULL REFERENCES vip_plan_versions(id),
+ perk_id text COLLATE "C" NOT NULL REFERENCES vip_perks(id),
+ perk_key text COLLATE "C" NOT NULL,
+ perk_name text COLLATE "C" NOT NULL,
+ position bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (plan_version_id, perk_id)
+);
+CREATE INDEX idx_vip_plan_perks_key ON vip_plan_perks (plan_version_id, perk_key);
+CREATE FUNCTION vip_plan_perks_no_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'vip_plan_perks belong to an immutable plan version'; END $$;
+CREATE TRIGGER vip_plan_perks_no_update BEFORE UPDATE ON vip_plan_perks FOR EACH ROW EXECUTE FUNCTION vip_plan_perks_no_update();
+CREATE FUNCTION vip_plan_perks_no_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'vip_plan_perks belong to an immutable plan version'; END $$;
+CREATE TRIGGER vip_plan_perks_no_delete BEFORE DELETE ON vip_plan_perks FOR EACH ROW EXECUTE FUNCTION vip_plan_perks_no_delete();
+
+CREATE TABLE vip_product_bindings (
+ id text COLLATE "C" PRIMARY KEY,
+ perk_id text COLLATE "C" NOT NULL REFERENCES vip_perks(id),
+ product text COLLATE "C" NOT NULL,
+ binding text COLLATE "C" NOT NULL,
+ config text COLLATE "C" NOT NULL DEFAULT '{}',
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')),
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ UNIQUE (perk_id, product, binding)
+);
+
+CREATE TABLE vip_member_preferences (
+ member_subject text COLLATE "C" NOT NULL,
+ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),
+ show_badge bigint NOT NULL DEFAULT 1,
+ listed bigint NOT NULL DEFAULT 0,
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (member_subject, creator_id)
+);
+
+CREATE TABLE vip_gated_resource_rules (
+ id text COLLATE "C" PRIMARY KEY,
+ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),
+ resource_service text COLLATE "C" NOT NULL,
+ resource_type text COLLATE "C" NOT NULL,
+ resource_id text COLLATE "C" NOT NULL,
+ requirement text COLLATE "C" NOT NULL CHECK (requirement IN ('member', 'plan', 'perk')),
+ plan_id text COLLATE "C" REFERENCES vip_plans(id),
+ perk_key text COLLATE "C",
+ sensitive bigint NOT NULL DEFAULT 0,
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
+ created_by text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),
+ CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)
+);
+-- One active rule per (resource, creator): the product names the owner when it asks (policies.js).
+DROP INDEX IF EXISTS idx_vip_rules_resource;
+CREATE UNIQUE INDEX idx_vip_rules_resource_creator
+ ON vip_gated_resource_rules (resource_service, resource_type, resource_id, creator_id) WHERE status = 'active';
+
+CREATE TABLE vip_migration_maps (
+ source text COLLATE "C" NOT NULL,
+ source_table text COLLATE "C" NOT NULL,
+ source_id text COLLATE "C" NOT NULL,
+ target_type text COLLATE "C",
+ target_id text COLLATE "C",
+ status text COLLATE "C" NOT NULL CHECK (status IN ('imported', 'linked', 'held', 'excluded')),
+ reason text COLLATE "C",
+ run_id text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (source, source_table, source_id)
+);
+
+CREATE TABLE vip_memberships (
+ id text COLLATE "C" PRIMARY KEY,
+ member_subject text COLLATE "C" NOT NULL,
+ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),
+ plan_id text COLLATE "C" REFERENCES vip_plans(id),
+ plan_version_id text COLLATE "C" REFERENCES vip_plan_versions(id),
+ billing_subscription_id text COLLATE "C",
+ origin text COLLATE "C" NOT NULL CHECK (origin IN ('checkout', 'billing', 'import')),
+ joined_at text COLLATE "C" NOT NULL,
+ terms_since text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ UNIQUE (member_subject, creator_id)
+);
+CREATE INDEX idx_vip_memberships_creator ON vip_memberships (creator_id);
+
+CREATE TABLE vip_entitlement_projection (
+ member_subject text COLLATE "C" NOT NULL,
+ creator_subject text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ active bigint NOT NULL,
+ expires_at text COLLATE "C",
+ cancel_at_period_end bigint NOT NULL DEFAULT 0,
+ subscription_id text COLLATE "C",
+ subscription_status text COLLATE "C",
+ source text COLLATE "C" NOT NULL CHECK (source IN ('event', 'billing_check', 'import')),
+ last_reason text COLLATE "C",
+ billing_event_id text COLLATE "C",
+ billing_as_of bigint NOT NULL,
+ synced_at bigint NOT NULL,
+ valid_until bigint NOT NULL,
+ last_used_at bigint,
+ PRIMARY KEY (member_subject, creator_subject, kind)
+);
+CREATE INDEX idx_vip_projection_creator ON vip_entitlement_projection (creator_subject, kind, active);
+
+CREATE TABLE vip_checkouts (
+ id text COLLATE "C" PRIMARY KEY,
+ member_subject text COLLATE "C" NOT NULL,
+ creator_id text COLLATE "C" NOT NULL REFERENCES vip_creators(id),
+ plan_id text COLLATE "C" NOT NULL REFERENCES vip_plans(id),
+ plan_version_id text COLLATE "C" NOT NULL REFERENCES vip_plan_versions(id),
+ provider text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL CHECK (status IN ('created', 'handed_off', 'paid', 'failed', 'used')),
+ billing_intent_id text COLLATE "C",
+ billing_subscription_id text COLLATE "C",
+ checkout_url text COLLATE "C",
+ checkout_ref text COLLATE "C",
+ error text COLLATE "C",
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX idx_vip_checkouts_member ON vip_checkouts (member_subject, creator_id, created_at);
+
+-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
+CREATE TABLE IF NOT EXISTS idempotency_receipts (
+ consumer text NOT NULL,
+ event_id text NOT NULL,
+ processed_at bigint NOT NULL,
+ PRIMARY KEY (consumer, event_id)
+);
+
+-- openvibe-sdk/events PostgreSQL outbox
+CREATE TABLE IF NOT EXISTS event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
+
+-- Added after the first SQLite schema (server/db.js COLUMNS): the creator's choice to show their member count.
+ALTER TABLE vip_creators ADD COLUMN show_member_count bigint NOT NULL DEFAULT 1;
+
+-- The one settings row, and the network itself as the creator of network-wide plans and perks (an import replaces
+-- both with the SQLite rows).
+INSERT INTO settings (id, created_at) VALUES (1, to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ON CONFLICT DO NOTHING;
+INSERT INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)
+ VALUES ('network', 'network', NULL, NULL, NULL, 'OpenVibe', 'system',
+ to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'), to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'))
+ ON CONFLICT DO NOTHING;
@@ -13,15 +13,33 @@
@@ -13,15 +13,33 @@
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -223,6 +241,15 @@
@@ -223,6 +241,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -305,6 +332,15 @@
@@ -305,6 +332,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -714,6 +750,49 @@
@@ -714,6 +750,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -778,12 +857,24 @@
@@ -778,12 +857,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -983,9 +1074,9 @@
@@ -983,9 +1074,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.49.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
+ "version": "0.76.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -999,18 +1090,38 @@
@@ -999,18 +1090,38 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.12.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
+ "version": "0.20.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
+ "integrity": "sha512-3Zwz8sJhWhnuw9GYr6ieIDp9CIl6ug0QqJE5ifDsk4ckrethBjcDzjadE1GSCGQDFXXLf/+bVWtI7X3VOVXmFQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.25.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
- "integrity": "sha512-oqZ+PvljrWi8IEbtg6bDYai17ZV2sALFx/ezMptniELSr10TgFUiGUQt+b1IWwiHv0T2Y1U2iSrkirJxMNgjSw==",
+ "version": "1.27.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
+ "integrity": "sha512-/H7dyFw+cURcdjyfbpyY7mNc6F+Pcb9TwkVwZHR+9d3M62q70KDhZLo/gZi71eaYxUjD8lO8tmbXu70EAa9paQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1031,6 +1142,134 @@
@@ -1031,6 +1142,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1154,6 +1393,27 @@
@@ -1154,6 +1393,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1369,6 +1629,21 @@
@@ -1369,6 +1629,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1496,6 +1771,15 @@
@@ -1496,6 +1771,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-vip",
"name": "openvibe-vip",
"version": "0.1.0",
"version": "0.1.0",
"private": true,
"private": true,
- "description": "OpenVibe.VIP — memberships on the OpenVibe network: plans, versioned plan terms, perks and gated-resource policy. Billing holds the entitlement truth; VIP holds plans and perks.",
+ "description": "OpenVibe.VIP \u2014 memberships on the OpenVibe network: plans, versioned plan terms, perks and gated-resource policy. Billing holds the entitlement truth; VIP holds plans and perks.",
"main": "server/index.js",
"main": "server/index.js",
"exports": {
"exports": {
".": "./server/index.js",
".": "./server/index.js",
@@ -14,9 +14,15 @@
@@ -14,9 +14,15 @@
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"test": "node test/run.js",
"subscribe": "node scripts/subscribe.js",
"subscribe": "node scripts/subscribe.js",
- "import-live": "node scripts/import-live.js"
+ "import-live": "node scripts/import-live.js",
+ "test:pg": "VIP_TEST_STORE=pg node test/run.js"
},
},
- "keywords": ["openvibe", "vip", "memberships", "entitlements"],
+ "keywords": [
+ "openvibe",
+ "vip",
+ "memberships",
+ "entitlements"
+ ],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"repository": "github:OpenVibers/OpenVibe.VIP",
"repository": "github:OpenVibers/OpenVibe.VIP",
@@ -25,12 +31,17 @@
@@ -25,12 +31,17 @@
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
+ "pg": "^8.23.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of VIP's SQLite database (VIP_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is
+ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
+ *
+ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
+ *
+ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts
+ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its
+ * name and columns.
+ */
+require('dotenv').config();
+const { runSqliteMigration } = require('openvibe-sdk/db');
+const configLib = require('../server/config');
+const { MIGRATIONS } = require('../server/db');
+
+const TABLES = {};
+
+if (require.main === module) {
+ const config = configLib.loadConfig();
+ runSqliteMigration({ service: 'vip', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
+ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
+}
+
+module.exports = { TABLES };
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
* (/embed, public pages behind nginx), and the signed Events deliveries at /internal/events (Billing's membership changes:
* (/embed, public pages behind nginx), and the signed Events deliveries at /internal/events (Billing's membership changes:
* Events pushes at its own pace, and a 429 would only make it retry and fall behind).
* Events pushes at its own pace, and a 429 would only make it retry and fall behind).
*/
*/
-const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
function actor(req) {
function actor(req) {
const p = req.principal;
const p = req.principal;
@@ -28,7 +28,7 @@ function actor(req) {
@@ -28,7 +28,7 @@ function actor(req) {
* limits(name, own) middleware for one app, plus limits.reads(name, skip): the defaults on GET/HEAD
* limits(name, own) middleware for one app, plus limits.reads(name, skip): the defaults on GET/HEAD
* requests, except those `skip(req)` names (routes that set their own).
* requests, except those `skip(req)` names (routes that set their own).
*/
*/
-function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, valkey = null }) {
const refused = registry
const refused = registry
? registry.counter({ name: 'vip_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
? registry.counter({ name: 'vip_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
@@ -36,6 +36,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
@@ -36,6 +36,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
actor,
actor,
now,
now,
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
onLimited(e) {
// The actor is a principal, a subject id or an address, never a token.
// The actor is a principal, a subject id or an address, never a token.
log.warn(`[VIP] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
log.warn(`[VIP] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
@@ -47,36 +47,36 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -47,36 +47,36 @@ function v1Router({ domain, apiAuth, limits }) {
const needAuth = (p) => { if (p.kind === 'anonymous') fail(401, 'auth.required', 'a Network service or user token is required'); };
const needAuth = (p) => { if (p.kind === 'anonymous') fail(401, 'auth.required', 'a Network service or user token is required'); };
/** A creator named by SubjectRef, usr_ id, username or 'network'. */
/** A creator named by SubjectRef, usr_ id, username or 'network'. */
- function findCreator(ref) {
- if (ref === 'network') return creators.network();
- if (ref && typeof ref === 'object') return creators.bySubject(userSubject(ref, 'creator'));
+ async function findCreator(ref) {
+ if (ref === 'network') return await creators.network();
+ if (ref && typeof ref === 'object') return await creators.bySubject(userSubject(ref, 'creator'));
const s = String(ref || '');
const s = String(ref || '');
- if (isUserSubject(s)) return creators.bySubject(s);
- return creators.byUsername(s);
+ if (isUserSubject(s)) return await creators.bySubject(s);
+ return await creators.byUsername(s);
}
}
/**
/**
* The creator a write acts for. Users: themselves (created on first use), or `network` for staff.
* The creator a write acts for. Users: themselves (created on first use), or `network` for staff.
* Services (holding `cap`): the creator named in the body.
* Services (holding `cap`): the creator named in the body.
*/
*/
- function actingCreator(req, ref, cap) {
+ async function actingCreator(req, ref, cap) {
const p = req.principal;
const p = req.principal;
needAuth(p);
needAuth(p);
if (p.kind === 'service') {
if (p.kind === 'service') {
if (!granted(p, cap)) denied(cap);
if (!granted(p, cap)) denied(cap);
- if (ref === 'network') return creators.network();
+ if (ref === 'network') return await creators.network();
if (!ref) fail(422, 'vip.invalid_input', 'creator is required ({ type: "user", id } or "network")');
if (!ref) fail(422, 'vip.invalid_input', 'creator is required ({ type: "user", id } or "network")');
- return creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'service' });
+ return await creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'service' });
}
}
if (ref === 'network') {
if (ref === 'network') {
if (!isStaff(p)) denied();
if (!isStaff(p)) denied();
- return creators.network();
+ return await creators.network();
}
}
if (ref && (typeof ref === 'object' ? ref.id : ref) !== p.subject) {
if (ref && (typeof ref === 'object' ? ref.id : ref) !== p.subject) {
if (!isStaff(p)) fail(403, 'vip.not_yours', 'you can only manage your own plans');
if (!isStaff(p)) fail(403, 'vip.not_yours', 'you can only manage your own plans');
- return creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'staff' });
+ return await creators.ensure({ subject: userSubject(ref, 'creator'), origin: 'staff' });
}
}
- return creators.ensure({ subject: p.subject, username: p.username, displayName: p.name, origin: 'self' });
+ return await creators.ensure({ subject: p.subject, username: p.username, displayName: p.name, origin: 'self' });
}
}
/** May the principal manage things owned by creator row `c`? */
/** May the principal manage things owned by creator row `c`? */
@@ -100,104 +100,104 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -100,104 +100,104 @@ function v1Router({ domain, apiAuth, limits }) {
return userSubject(value, 'subject');
return userSubject(value, 'subject');
}
}
- const presentPlanFull = (plan) => ({ ...plans.present(plan), creator: creators.present(creators.byId(plan.creator_id)) });
+ const presentPlanFull = async (plan) => ({ ...await plans.present(plan), creator: creators.present(await creators.byId(plan.creator_id)) });
const trace = (req) => req.ov && req.ov.traceparent;
const trace = (req) => req.ov && req.ov.traceparent;
// ── Creators (public) ────────────────────────────────────
// ── Creators (public) ────────────────────────────────────
- r.get('/creators/:ref', wrap((req, res) => {
- const c = findCreator(req.params.ref);
+ r.get('/creators/:ref', wrap(async (req, res) => {
+ const c = await findCreator(req.params.ref);
if (!c) fail(404, 'vip.creator_not_found', 'no such creator');
if (!c) fail(404, 'vip.creator_not_found', 'no such creator');
const own = mayManage(req.principal, c, CAP.planList);
const own = mayManage(req.principal, c, CAP.planList);
res.json({
res.json({
creator: creators.present(c),
creator: creators.present(c),
- plans: plans.list({ creatorId: c.id, includeDrafts: own }).map((p) => plans.present(p)),
- perks: perks.list({ creatorId: c.id, includeNetwork: false }).map((p) => perks.present(p)),
+ plans: (await Promise.all((await plans.list({ creatorId: c.id, includeDrafts: own })).map(async (p) => await plans.present(p)))),
+ perks: (await Promise.all((await perks.list({ creatorId: c.id, includeNetwork: false })).map(async (p) => await perks.present(p)))),
});
});
}));
}));
/** The public membership card (server/domain/cards.js): what /embed/:username/card.json serves. */
/** The public membership card (server/domain/cards.js): what /embed/:username/card.json serves. */
r.get('/creators/:ref/card', wrap(async (req, res) => {
r.get('/creators/:ref/card', wrap(async (req, res) => {
- const card = await domain.cards.card(findCreator(req.params.ref));
+ const card = await domain.cards.card(await findCreator(req.params.ref));
if (!card) fail(404, 'vip.creator_not_found', 'no public membership card for this creator');
if (!card) fail(404, 'vip.creator_not_found', 'no public membership card for this creator');
res.json({ card });
res.json({ card });
}));
}));
// ── Plans ────────────────────────────────────────────────
// ── Plans ────────────────────────────────────────────────
- r.get('/plans', wrap((req, res) => {
- const c = findCreator(req.query.creator);
+ r.get('/plans', wrap(async (req, res) => {
+ const c = await findCreator(req.query.creator);
if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');
if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');
const include = String(req.query.include || '').split(',');
const include = String(req.query.include || '').split(',');
const own = mayManage(req.principal, c, CAP.planList);
const own = mayManage(req.principal, c, CAP.planList);
- const list = plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });
- res.json({ creator: creators.present(c), plans: list.map((p) => plans.present(p)) });
+ const list = await plans.list({ creatorId: c.id, includeDrafts: own && include.includes('drafts'), includeArchived: own && include.includes('archived') });
+ res.json({ creator: creators.present(c), plans: (await Promise.all(list.map(async (p) => await plans.present(p)))) });
}));
}));
- const visiblePlan = (req, id) => {
- const plan = plans.byId(id);
+ const visiblePlan = async (req, id) => {
+ const plan = await plans.byId(id);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${id}`);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${id}`);
- if (plan.status !== 'published' && !mayManage(req.principal, creators.byId(plan.creator_id), CAP.planList)) fail(404, 'vip.plan_not_found', `no plan ${id}`);
+ if (plan.status !== 'published' && !mayManage(req.principal, await creators.byId(plan.creator_id), CAP.planList)) fail(404, 'vip.plan_not_found', `no plan ${id}`);
return plan;
return plan;
};
};
- r.get('/plans/:id', wrap((req, res) => res.json({ plan: presentPlanFull(visiblePlan(req, req.params.id)) })));
- r.get('/plans/:id/versions', wrap((req, res) => {
- const plan = visiblePlan(req, req.params.id);
- const own = mayManage(req.principal, creators.byId(plan.creator_id), CAP.planList);
- const all = plans.versions(plan.id).filter((v) => own || v.published_at).map(plans.presentVersion);
+ r.get('/plans/:id', wrap(async (req, res) => res.json({ plan: await presentPlanFull(await visiblePlan(req, req.params.id)) })));
+ r.get('/plans/:id/versions', wrap(async (req, res) => {
+ const plan = await visiblePlan(req, req.params.id);
+ const own = mayManage(req.principal, await creators.byId(plan.creator_id), CAP.planList);
+ const all = (await Promise.all((await plans.versions(plan.id)).filter((v) => own || v.published_at).map(plans.presentVersion)));
res.json({ plan_id: plan.id, versions: all });
res.json({ plan_id: plan.id, versions: all });
}));
}));
- r.post('/plans', create, wrap((req, res) => {
+ r.post('/plans', create, wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
- const c = actingCreator(req, b.creator, CAP.planCreate);
+ const c = await actingCreator(req, b.creator, CAP.planCreate);
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
- const plan = plans.create({
+ const plan = await plans.create({
creatorId: c.id, slug: b.slug, name: b.name, description: b.description, benefits: b.benefits, perks: b.perks,
creatorId: c.id, slug: b.slug, name: b.name, description: b.description, benefits: b.benefits, perks: b.perks,
billingKind: b.billing_kind, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),
billingKind: b.billing_kind, publish: bool(b.publish), changeNote: b.change_note, actor, traceparent: trace(req),
});
});
- res.status(201).json({ plan: presentPlanFull(plan) });
+ res.status(201).json({ plan: await presentPlanFull(plan) });
}));
}));
- const managedPlan = (req, cap) => {
- const plan = plans.byId(req.params.id);
+ const managedPlan = async (req, cap) => {
+ const plan = await plans.byId(req.params.id);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${req.params.id}`);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${req.params.id}`);
- requireManage(req, creators.byId(plan.creator_id), cap);
+ requireManage(req, await creators.byId(plan.creator_id), cap);
return plan;
return plan;
};
};
- r.patch('/plans/:id', edit, wrap((req, res) => {
- const plan = managedPlan(req, CAP.planUpdate);
+ r.patch('/plans/:id', edit, wrap(async (req, res) => {
+ const plan = await managedPlan(req, CAP.planUpdate);
const b = req.body || {};
const b = req.body || {};
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
- const out = plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });
- res.json({ plan: presentPlanFull(out.plan), version: plans.presentVersion(out.version), unchanged: out.unchanged });
+ const out = await plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });
+ res.json({ plan: await presentPlanFull(out.plan), version: await plans.presentVersion(out.version), unchanged: out.unchanged });
}));
}));
- r.post('/plans/:id/publish', create, wrap((req, res) => {
- const plan = managedPlan(req, CAP.planUpdate);
- const out = plans.publish(plan.id, { traceparent: trace(req) });
- res.json({ plan: presentPlanFull(out.plan), replay: out.replay });
+ r.post('/plans/:id/publish', create, wrap(async (req, res) => {
+ const plan = await managedPlan(req, CAP.planUpdate);
+ const out = await plans.publish(plan.id, { traceparent: trace(req) });
+ res.json({ plan: await presentPlanFull(out.plan), replay: out.replay });
}));
}));
- r.post('/plans/:id/archive', create, wrap((req, res) => {
- const plan = managedPlan(req, CAP.planArchive);
- res.json({ plan: presentPlanFull(plans.archive(plan.id)) });
+ r.post('/plans/:id/archive', create, wrap(async (req, res) => {
+ const plan = await managedPlan(req, CAP.planArchive);
+ res.json({ plan: await presentPlanFull(await plans.archive(plan.id)) });
}));
}));
// ── Perks ────────────────────────────────────────────────
// ── Perks ────────────────────────────────────────────────
- r.get('/perks', wrap((req, res) => {
- const c = findCreator(req.query.creator);
+ r.get('/perks', wrap(async (req, res) => {
+ const c = await findCreator(req.query.creator);
if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');
if (!c) fail(404, 'vip.creator_not_found', 'creator must name a creator (usr_ id, username or network)');
const own = mayManage(req.principal, c, CAP.perkList);
const own = mayManage(req.principal, c, CAP.perkList);
- const list = perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });
- res.json({ perks: list.map((p) => perks.present(p)) });
+ const list = await perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });
+ res.json({ perks: (await Promise.all(list.map(async (p) => await perks.present(p)))) });
}));
}));
- r.post('/perks', create, wrap((req, res) => {
+ r.post('/perks', create, wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
- const c = actingCreator(req, b.creator, CAP.perkCreate);
+ const c = await actingCreator(req, b.creator, CAP.perkCreate);
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
- const p = perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });
- res.status(201).json({ perk: perks.present(p) });
+ const p = await perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });
+ res.status(201).json({ perk: await perks.present(p) });
}));
}));
- r.patch('/perks/:id', edit, wrap((req, res) => {
- const p = perks.byId(req.params.id);
+ r.patch('/perks/:id', edit, wrap(async (req, res) => {
+ const p = await perks.byId(req.params.id);
if (!p) fail(404, 'vip.perk_not_found', `no perk ${req.params.id}`);
if (!p) fail(404, 'vip.perk_not_found', `no perk ${req.params.id}`);
- requireManage(req, creators.byId(p.creator_id), CAP.perkUpdate);
+ requireManage(req, await creators.byId(p.creator_id), CAP.perkUpdate);
const b = req.body || {};
const b = req.body || {};
- res.json({ perk: perks.present(perks.update(p.id, { name: b.name, description: b.description, kind: b.kind, status: b.status, bindings: b.bindings })) });
+ res.json({ perk: await perks.present(await perks.update(p.id, { name: b.name, description: b.description, kind: b.kind, status: b.status, bindings: b.bindings })) });
}));
}));
// ── Checkout hand-off (Billing) ──────────────────────────
// ── Checkout hand-off (Billing) ──────────────────────────
@@ -233,29 +233,29 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -233,29 +233,29 @@ function v1Router({ domain, apiAuth, limits }) {
const member = memberSubject(req, req.params.subject, CAP.status);
const member = memberSubject(req, req.params.subject, CAP.status);
const mode = ['projection', 'authoritative'].includes(req.query.mode) ? req.query.mode : 'auto';
const mode = ['projection', 'authoritative'].includes(req.query.mode) ? req.query.mode : 'auto';
const list = [];
const list = [];
- for (const m of memberships.forMember(member)) {
- const c = creators.byId(m.creator_id);
+ for (const m of await memberships.forMember(member)) {
+ const c = await creators.byId(m.creator_id);
const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req) }) : null;
const ent = c && c.subject ? await entitlements.check(member, c.subject, { mode, traceparent: trace(req) }) : null;
- list.push({ ...memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: memberships.preferences(member, m.creator_id) });
+ list.push({ ...await memberships.present(m), creator: creators.present(c), entitlement: ent, preferences: await memberships.preferences(member, m.creator_id) });
}
}
res.json({ member: { type: 'user', id: member }, memberships: list });
res.json({ member: { type: 'user', id: member }, memberships: list });
}));
}));
- const creatorParam = (req) => {
- const c = findCreator(req.params.creator);
+ const creatorParam = async (req) => {
+ const c = await findCreator(req.params.creator);
if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
return c;
return c;
};
};
// Cancelling is handed to Billing: the member does it once.
// Cancelling is handed to Billing: the member does it once.
r.post('/memberships/:creator/cancel', limits('vip.membership.cancel', { minute: 5, hour: 30 }), wrap(async (req, res) => {
r.post('/memberships/:creator/cancel', limits('vip.membership.cancel', { minute: 5, hour: 30 }), wrap(async (req, res) => {
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');
- const c = creatorParam(req);
+ const c = await creatorParam(req);
res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));
res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));
}));
}));
- r.put('/memberships/:creator/preferences', edit, wrap((req, res) => {
+ r.put('/memberships/:creator/preferences', edit, wrap(async (req, res) => {
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');
- const c = creatorParam(req);
+ const c = await creatorParam(req);
const b = req.body || {};
const b = req.body || {};
- res.json({ preferences: memberships.setPreferences(req.principal.subject, c.id, { showBadge: b.show_badge === undefined ? undefined : bool(b.show_badge), listed: b.listed === undefined ? undefined : bool(b.listed) }) });
+ res.json({ preferences: await memberships.setPreferences(req.principal.subject, c.id, { showBadge: b.show_badge === undefined ? undefined : bool(b.show_badge), listed: b.listed === undefined ? undefined : bool(b.listed) }) });
}));
}));
// ── Entitlement check (projection + authoritative fallback) ──
// ── Entitlement check (projection + authoritative fallback) ──
@@ -266,7 +266,7 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -266,7 +266,7 @@ function v1Router({ domain, apiAuth, limits }) {
const raw = src.creator && typeof src.creator === 'object' ? src.creator.id : src.creator;
const raw = src.creator && typeof src.creator === 'object' ? src.creator.id : src.creator;
let creatorSubject = isUserSubject(raw) ? raw : null;
let creatorSubject = isUserSubject(raw) ? raw : null;
if (!creatorSubject) {
if (!creatorSubject) {
- const c = findCreator(raw);
+ const c = await findCreator(raw);
if (!c || !c.subject) fail(422, 'vip.invalid_input', 'creator must be a user SubjectRef, usr_ id or VIP username');
if (!c || !c.subject) fail(422, 'vip.invalid_input', 'creator must be a user SubjectRef, usr_ id or VIP username');
creatorSubject = c.subject;
creatorSubject = c.subject;
}
}
@@ -278,9 +278,9 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -278,9 +278,9 @@ function v1Router({ domain, apiAuth, limits }) {
const product = String(src.product);
const product = String(src.product);
if (!/^[a-z][a-z0-9-]{1,39}$/.test(product)) fail(422, 'vip.invalid_input', 'product must be a service id such as chat or blog');
if (!/^[a-z][a-z0-9-]{1,39}$/.test(product)) fail(422, 'vip.invalid_input', 'product must be a service id such as chat or blog');
out.product = product;
out.product = product;
- out.product_perks = out.active ? perks.forMembership(out.membership, product) : [];
- const c = creators.bySubject(creatorSubject);
- out.preferences = c ? memberships.preferences(member, c.id) : { show_badge: true, listed: false };
+ out.product_perks = out.active ? await perks.forMembership(out.membership, product) : [];
+ const c = await creators.bySubject(creatorSubject);
+ out.preferences = c ? await memberships.preferences(member, c.id) : { show_badge: true, listed: false };
}
}
res.json(out);
res.json(out);
});
});
@@ -293,44 +293,44 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -293,44 +293,44 @@ function v1Router({ domain, apiAuth, limits }) {
r.post('/entitlements/check', entitlementLimit, entitlementCheck);
r.post('/entitlements/check', entitlementLimit, entitlementCheck);
// ── Gated-resource policy ────────────────────────────────
// ── Gated-resource policy ────────────────────────────────
- r.get('/policies', wrap((req, res) => {
+ r.get('/policies', wrap(async (req, res) => {
const p = req.principal;
const p = req.principal;
needAuth(p);
needAuth(p);
if (req.query.service || req.query.type || req.query.id) {
if (req.query.service || req.query.type || req.query.id) {
const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });
const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });
// Rules are per creator: name the owner (services must; a person defaults to themselves).
// Rules are per creator: name the owner (services must; a person defaults to themselves).
- const owner = req.query.owner ? policies.ownerCreator(req.query.owner === 'network' ? 'network' : String(req.query.owner))
- : (p.kind === 'user' ? creators.bySubject(p.subject) : null);
+ const owner = req.query.owner ? await policies.ownerCreator(req.query.owner === 'network' ? 'network' : String(req.query.owner))
+ : (p.kind === 'user' ? await creators.bySubject(p.subject) : null);
if (!req.query.owner && p.kind !== 'user') fail(422, 'vip.invalid_input', 'owner (the creator\'s usr_ id or network) is required');
if (!req.query.owner && p.kind !== 'user') fail(422, 'vip.invalid_input', 'owner (the creator\'s usr_ id or network) is required');
- const rule = owner ? policies.forResource(resource, owner.id) : null;
+ const rule = owner ? await policies.forResource(resource, owner.id) : null;
if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);
if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);
- if (!rule || (p.kind === 'user' && !mayManage(p, creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');
- return res.json({ rule: policies.present(rule) });
+ if (!rule || (p.kind === 'user' && !mayManage(p, await creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');
+ return res.json({ rule: await policies.present(rule) });
}
}
- const c = findCreator(req.query.creator || (p.kind === 'user' ? p.subject : null));
+ const c = await findCreator(req.query.creator || (p.kind === 'user' ? p.subject : null));
if (!c) fail(404, 'vip.creator_not_found', 'no such creator');
if (!c) fail(404, 'vip.creator_not_found', 'no such creator');
requireManage(req, c, CAP.policyGet);
requireManage(req, c, CAP.policyGet);
- return res.json({ rules: policies.list(c.id).map(policies.present) });
+ return res.json({ rules: await Promise.all((await policies.list(c.id)).map(policies.present)) });
}));
}));
- r.get('/policies/:id', wrap((req, res) => {
- const rule = policies.byId(req.params.id);
+ r.get('/policies/:id', wrap(async (req, res) => {
+ const rule = await policies.byId(req.params.id);
if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);
if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);
- requireManage(req, creators.byId(rule.creator_id), CAP.policyGet);
- res.json({ rule: policies.present(rule) });
+ requireManage(req, await creators.byId(rule.creator_id), CAP.policyGet);
+ res.json({ rule: await policies.present(rule) });
}));
}));
- r.post('/policies', edit, wrap((req, res) => {
+ r.post('/policies', edit, wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
- const c = actingCreator(req, b.creator, CAP.policySet);
+ const c = await actingCreator(req, b.creator, CAP.policySet);
if (c.kind !== 'creator') fail(422, 'vip.invalid_input', 'gated resources belong to a creator');
if (c.kind !== 'creator') fail(422, 'vip.invalid_input', 'gated resources belong to a creator');
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
- const rule = policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });
- res.status(201).json({ rule: policies.present(rule) });
+ const rule = await policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });
+ res.status(201).json({ rule: await policies.present(rule) });
}));
}));
- r.delete('/policies/:id', edit, wrap((req, res) => {
- const rule = policies.byId(req.params.id);
+ r.delete('/policies/:id', edit, wrap(async (req, res) => {
+ const rule = await policies.byId(req.params.id);
if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);
if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);
- requireManage(req, creators.byId(rule.creator_id), CAP.policySet);
- res.json({ rule: policies.present(policies.disable(rule.id)) });
+ requireManage(req, await creators.byId(rule.creator_id), CAP.policySet);
+ res.json({ rule: await policies.present(await policies.disable(rule.id)) });
}));
}));
// Evaluation is the gates' hot path too (see the entitlement check).
// Evaluation is the gates' hot path too (see the entitlement check).
r.post('/policies/evaluate', byKind(limits('vip.resource.policy.evaluate', { minute: 1200, hour: 30000 }), limits('vip.resource.policy.evaluate')), wrap(async (req, res) => {
r.post('/policies/evaluate', byKind(limits('vip.resource.policy.evaluate', { minute: 1200, hour: 30000 }), limits('vip.resource.policy.evaluate')), wrap(async (req, res) => {
@@ -353,22 +353,22 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -353,22 +353,22 @@ function v1Router({ domain, apiAuth, limits }) {
// ── Creator members ──────────────────────────────────────
// ── Creator members ──────────────────────────────────────
r.get('/creators/:ref/members', wrap(async (req, res) => {
r.get('/creators/:ref/members', wrap(async (req, res) => {
- const c = findCreator(req.params.ref);
+ const c = await findCreator(req.params.ref);
if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
requireManage(req, c, CAP.members);
requireManage(req, c, CAP.members);
- const byMember = new Map(memberships.forCreator(c.id).map((m) => [m.member_subject, m]));
- const row = (subject, extra) => ({ member: { type: 'user', id: subject }, ...extra, membership: memberships.present(byMember.get(subject)) });
+ const byMember = new Map((await memberships.forCreator(c.id)).map((m) => [m.member_subject, m]));
+ const row = async (subject, extra) => ({ member: { type: 'user', id: subject }, ...extra, membership: await memberships.present(byMember.get(subject)) });
try {
try {
// Authoritative: Billing's active subscriptions to this creator.
// Authoritative: Billing's active subscriptions to this creator.
const out = await billing.listSubscriptions({ streamer: c.subject, status: 'active' });
const out = await billing.listSubscriptions({ streamer: c.subject, status: 'active' });
- const members = (out.subscriptions || []).map((s) => row(s.subscriber.id, {
+ const members = (await Promise.all((out.subscriptions || []).map(async (s) => await row(s.subscriber.id, {
current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, subscription_id: s.id,
current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, subscription_id: s.id,
- }));
+ }))));
return res.json({ creator: creators.present(c), source: 'billing', members });
return res.json({ creator: creators.present(c), source: 'billing', members });
} catch {
} catch {
- const members = entitlements.projectedMembers(c.subject).map((pr) => row(pr.member_subject, {
+ const members = (await Promise.all((await entitlements.projectedMembers(c.subject)).map(async (pr) => await row(pr.member_subject, {
current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, subscription_id: pr.subscription_id,
current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, subscription_id: pr.subscription_id,
- }));
+ }))));
return res.json({ creator: creators.present(c), source: 'projection', note: 'Billing did not answer; this list is VIP\'s projection (fresh rows only) and may be incomplete', members });
return res.json({ creator: creators.present(c), source: 'projection', note: 'Billing did not answer; this list is VIP\'s projection (fresh rows only) and may be incomplete', members });
}
}
}));
}));
@@ -378,9 +378,9 @@ function v1Router({ domain, apiAuth, limits }) {
@@ -378,9 +378,9 @@ function v1Router({ domain, apiAuth, limits }) {
/** Async-safe handler: VipError → problem+json; anything else → 500. */
/** Async-safe handler: VipError → problem+json; anything else → 500. */
function wrap(fn) {
function wrap(fn) {
- return (req, res, next) => {
+ return async (req, res, next) => {
try {
try {
- const p = fn(req, res, next);
+ const p = await fn(req, res, next);
if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
} catch (e) { sendError(req, res, e); }
} catch (e) { sendError(req, res, e); }
};
};
@@ -39,9 +39,11 @@ const pages = require('./web/pages');
@@ -39,9 +39,11 @@ const pages = require('./web/pages');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const config = opts.config || loadConfig();
const config = opts.config || loadConfig();
- const db = opts.db || openDb(config.dbPath);
+ const log0 = opts.log || console;
+ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a migrated handle.
+ const db = opts.db || await openDb(config, { log: log0 });
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const log = opts.log || console;
const log = opts.log || console;
const now = opts.now || (() => Date.now());
const now = opts.now || (() => Date.now());
@@ -60,12 +62,12 @@ function createApp(opts = {}) {
@@ -60,12 +62,12 @@ function createApp(opts = {}) {
// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
// HTTP golden signals by route template, process metrics, release_info; GET /metrics answers
// direct loopback callers only (Track O). Gauges: outbox backlog and projection staleness.
// direct loopback callers only (Track O). Gauges: outbox backlog and projection staleness.
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'vip', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'vip', release: release.release });
- metrics.registry.gauge({ name: 'vip_outbox_pending', help: 'Events waiting in the outbox', collect: () => outbox.outbox.pending() });
+ metrics.registry.gauge({ name: 'vip_outbox_pending', help: 'Events waiting in the outbox', collect: async () => await outbox.outbox.pending() });
metrics.registry.gauge({
metrics.registry.gauge({
name: 'vip_projection_rows', help: 'Entitlement projection rows by freshness', labelNames: ['state'],
name: 'vip_projection_rows', help: 'Entitlement projection rows by freshness', labelNames: ['state'],
- collect: () => {
+ collect: async () => {
const t = now();
const t = now();
- const r = db.prepare('SELECT SUM(CASE WHEN valid_until >= ? THEN 1 ELSE 0 END) AS fresh, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t, t);
+ const r = await db.prepare('SELECT SUM(CASE WHEN valid_until >= ? THEN 1 ELSE 0 END) AS fresh, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t, t);
return [{ labels: { state: 'fresh' }, value: r.fresh || 0 }, { labels: { state: 'stale' }, value: r.stale || 0 }];
return [{ labels: { state: 'fresh' }, value: r.fresh || 0 }, { labels: { state: 'stale' }, value: r.stale || 0 }];
},
},
});
});
@@ -86,7 +88,9 @@ function createApp(opts = {}) {
@@ -86,7 +88,9 @@ function createApp(opts = {}) {
app.use(cookieParser());
app.use(cookieParser());
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'vip', version: VERSION, release: release.release, events: outbox.status() }));
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'vip', version: VERSION, release: release.release, events: outbox.status() }));
- const readiness = createVipReadiness({ db, keys, config, outbox, now, release: release.release, fetchImpl });
+ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ const readiness = createVipReadiness({ db, keys, config, outbox, now, release: release.release, fetchImpl, valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
@@ -95,7 +99,7 @@ function createApp(opts = {}) {
@@ -95,7 +99,7 @@ function createApp(opts = {}) {
app.use('/internal', consumer.router);
app.use('/internal', consumer.router);
// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
// opts.limitsNow: the limiter's clock (tests).
// opts.limitsNow: the limiter's clock (tests).
- const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log });
+ const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
@@ -69,7 +69,7 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
@@ -69,7 +69,7 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
} catch (e) {
} catch (e) {
throw new BillingCallError(`Billing unreachable: ${e.message}`);
throw new BillingCallError(`Billing unreachable: ${e.message}`);
}
}
- if (res.status === 401 && !retried) { tokensFor(cap).invalidate(); return call(cap, method, path, { body, key, traceparent, retried: true }); }
+ if (res.status === 401 && !retried) { tokensFor(cap).invalidate(); return await call(cap, method, path, { body, key, traceparent, retried: true }); }
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!res.ok) {
const code = (data && (data.code || data.error)) || `http_${res.status}`;
const code = (data && (data.code || data.error)) || `http_${res.status}`;
@@ -82,21 +82,21 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
@@ -82,21 +82,21 @@ function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient
return {
return {
/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
/** { intent, checkout_url } — a subscription checkout for `subject` to `creator`. */
- createIntent: ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => call(CAPS.intent, 'POST', '/api/v1/intents', {
+ createIntent: async ({ provider, subject, creator, autoRenew, successUrl, cancelUrl, key, traceparent }) => await call(CAPS.intent, 'POST', '/api/v1/intents', {
body: { provider, kind: 'subscription', subject: user(subject), streamer: user(creator), auto_renew: !!autoRenew, success_url: successUrl, cancel_url: cancelUrl },
body: { provider, kind: 'subscription', subject: user(subject), streamer: user(creator), auto_renew: !!autoRenew, success_url: successUrl, cancel_url: cancelUrl },
key, traceparent,
key, traceparent,
}),
}),
/** { subscription, entitlement, transaction } — one period paid from the member's credit. */
/** { subscription, entitlement, transaction } — one period paid from the member's credit. */
- subscribeWithCredit: ({ subscriber, creator, autoRenew, key, traceparent }) => call(CAPS.subscription, 'POST', '/api/v1/subscriptions', {
+ subscribeWithCredit: async ({ subscriber, creator, autoRenew, key, traceparent }) => await call(CAPS.subscription, 'POST', '/api/v1/subscriptions', {
body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false },
body: { subscriber: user(subscriber), streamer: user(creator), source: 'credit', auto_renew: autoRenew !== false },
key, traceparent,
key, traceparent,
}),
}),
- cancelSubscription: ({ id, key, traceparent }) => call(CAPS.subscription, 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),
- getSubscription: (id) => call(CAPS.entitlement, 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),
- listSubscriptions: ({ streamer, subscriber, status } = {}) => call(CAPS.entitlement, 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),
+ cancelSubscription: async ({ id, key, traceparent }) => await call(CAPS.subscription, 'POST', `/api/v1/subscriptions/${encodeURIComponent(id)}/cancel`, { body: {}, key, traceparent }),
+ getSubscription: async (id) => await call(CAPS.entitlement, 'GET', `/api/v1/subscriptions/${encodeURIComponent(id)}`),
+ listSubscriptions: async ({ streamer, subscriber, status } = {}) => await call(CAPS.entitlement, 'GET', `/api/v1/subscriptions${qs({ streamer, subscriber, status })}`),
/** { active, expires_at, subscription } — the authoritative answer. */
/** { active, expires_at, subscription } — the authoritative answer. */
- entitlement: (subject, creator) => call(CAPS.entitlement, 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),
- rates: () => call(CAPS.entitlement, 'GET', '/api/v1/rates'),
+ entitlement: async (subject, creator) => await call(CAPS.entitlement, 'GET', `/api/v1/entitlements/${encodeURIComponent(subject)}${qs({ streamer: creator })}`),
+ rates: async () => await call(CAPS.entitlement, 'GET', '/api/v1/rates'),
baseUrl: () => base,
baseUrl: () => base,
};
};
}
}
@@ -28,6 +28,10 @@ function loadConfig(env = process.env) {
@@ -28,6 +28,10 @@ function loadConfig(env = process.env) {
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:vip:' },
+ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.VIP_DB_PATH || './data/vip.db',
dbPath: env.VIP_DB_PATH || './data/vip.db',
// Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.
// Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * VIP's own SQLite database (ADR-007: one database per service), WAL, created on boot, idempotent.
+ * VIP's own PostgreSQL database (ADR-007: one database per service; ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
*
*
* Authority tables (the nine the charter names; VIP is their only writer):
* Authority tables (the nine the charter names; VIP is their only writer):
* vip_creators a creator (or the network) that offers memberships
* vip_creators a creator (or the network) that offers memberships
@@ -25,246 +25,29 @@
@@ -25,246 +25,29 @@
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { createDb } = require('openvibe-sdk/db');
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS settings (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- created_at TEXT NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS vip_creators (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL CHECK (kind IN ('creator', 'network')),
- subject TEXT UNIQUE,
- username TEXT,
- username_lc TEXT UNIQUE,
- display_name TEXT,
- bio TEXT,
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')),
- origin TEXT NOT NULL DEFAULT 'self' CHECK (origin IN ('self', 'service', 'import', 'staff', 'system')),
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- CHECK ((kind = 'network' AND subject IS NULL) OR (kind = 'creator' AND subject IS NOT NULL))
-);
-
-CREATE TABLE IF NOT EXISTS vip_plans (
- id TEXT PRIMARY KEY,
- creator_id TEXT NOT NULL REFERENCES vip_creators(id),
- slug TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
- billing_kind TEXT CHECK (billing_kind IS NULL OR billing_kind IN ('channel_subscription')),
- current_version_id TEXT,
- latest_version INTEGER NOT NULL DEFAULT 0,
- sort INTEGER NOT NULL DEFAULT 0,
- created_by TEXT,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- archived_at TEXT,
- UNIQUE (creator_id, slug)
-);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_plans_one_billing_product
- ON vip_plans (creator_id, billing_kind) WHERE status = 'published' AND billing_kind IS NOT NULL;
-
-CREATE TABLE IF NOT EXISTS vip_plan_versions (
- id TEXT PRIMARY KEY,
- plan_id TEXT NOT NULL REFERENCES vip_plans(id),
- version INTEGER NOT NULL,
- name TEXT NOT NULL,
- description TEXT,
- benefits TEXT NOT NULL DEFAULT '[]',
- terms TEXT NOT NULL,
- change_note TEXT,
- created_by TEXT,
- created_at TEXT NOT NULL,
- published_at TEXT,
- UNIQUE (plan_id, version)
-);
-CREATE TRIGGER IF NOT EXISTS vip_plan_versions_no_rewrite BEFORE UPDATE ON vip_plan_versions
-WHEN NEW.id IS NOT OLD.id OR NEW.plan_id IS NOT OLD.plan_id OR NEW.version IS NOT OLD.version
- OR NEW.name IS NOT OLD.name OR NEW.description IS NOT OLD.description OR NEW.benefits IS NOT OLD.benefits
- OR NEW.terms IS NOT OLD.terms OR NEW.change_note IS NOT OLD.change_note OR NEW.created_by IS NOT OLD.created_by
- OR NEW.created_at IS NOT OLD.created_at OR (OLD.published_at IS NOT NULL AND NEW.published_at IS NOT OLD.published_at)
-BEGIN SELECT RAISE(ABORT, 'vip_plan_versions are immutable: edit a plan by creating a new version'); END;
-CREATE TRIGGER IF NOT EXISTS vip_plan_versions_no_delete BEFORE DELETE ON vip_plan_versions
-BEGIN SELECT RAISE(ABORT, 'vip_plan_versions are immutable: edit a plan by creating a new version'); END;
-
-CREATE TABLE IF NOT EXISTS vip_perks (
- id TEXT PRIMARY KEY,
- creator_id TEXT NOT NULL REFERENCES vip_creators(id),
- key TEXT NOT NULL,
- name TEXT NOT NULL,
- description TEXT,
- kind TEXT NOT NULL DEFAULT 'other' CHECK (kind IN ('badge', 'emote', 'gated_content', 'room', 'role', 'other')),
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
- created_by TEXT,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- UNIQUE (creator_id, key)
-);
-
-CREATE TABLE IF NOT EXISTS vip_plan_perks (
- plan_version_id TEXT NOT NULL REFERENCES vip_plan_versions(id),
- perk_id TEXT NOT NULL REFERENCES vip_perks(id),
- perk_key TEXT NOT NULL,
- perk_name TEXT NOT NULL,
- position INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (plan_version_id, perk_id)
-);
-CREATE INDEX IF NOT EXISTS idx_vip_plan_perks_key ON vip_plan_perks (plan_version_id, perk_key);
-CREATE TRIGGER IF NOT EXISTS vip_plan_perks_no_update BEFORE UPDATE ON vip_plan_perks
-BEGIN SELECT RAISE(ABORT, 'vip_plan_perks belong to an immutable plan version'); END;
-CREATE TRIGGER IF NOT EXISTS vip_plan_perks_no_delete BEFORE DELETE ON vip_plan_perks
-BEGIN SELECT RAISE(ABORT, 'vip_plan_perks belong to an immutable plan version'); END;
-
-CREATE TABLE IF NOT EXISTS vip_product_bindings (
- id TEXT PRIMARY KEY,
- perk_id TEXT NOT NULL REFERENCES vip_perks(id),
- product TEXT NOT NULL,
- binding TEXT NOT NULL,
- config TEXT NOT NULL DEFAULT '{}',
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')),
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- UNIQUE (perk_id, product, binding)
-);
-
-CREATE TABLE IF NOT EXISTS vip_member_preferences (
- member_subject TEXT NOT NULL,
- creator_id TEXT NOT NULL REFERENCES vip_creators(id),
- show_badge INTEGER NOT NULL DEFAULT 1,
- listed INTEGER NOT NULL DEFAULT 0,
- updated_at TEXT NOT NULL,
- PRIMARY KEY (member_subject, creator_id)
-);
-
-CREATE TABLE IF NOT EXISTS vip_gated_resource_rules (
- id TEXT PRIMARY KEY,
- creator_id TEXT NOT NULL REFERENCES vip_creators(id),
- resource_service TEXT NOT NULL,
- resource_type TEXT NOT NULL,
- resource_id TEXT NOT NULL,
- requirement TEXT NOT NULL CHECK (requirement IN ('member', 'plan', 'perk')),
- plan_id TEXT REFERENCES vip_plans(id),
- perk_key TEXT,
- sensitive INTEGER NOT NULL DEFAULT 0,
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
- created_by TEXT,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),
- CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)
-);
-DROP INDEX IF EXISTS idx_vip_rules_resource;
-CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource_creator
- ON vip_gated_resource_rules (resource_service, resource_type, resource_id, creator_id) WHERE status = 'active';
-
-CREATE TABLE IF NOT EXISTS vip_migration_maps (
- source TEXT NOT NULL,
- source_table TEXT NOT NULL,
- source_id TEXT NOT NULL,
- target_type TEXT,
- target_id TEXT,
- status TEXT NOT NULL CHECK (status IN ('imported', 'linked', 'held', 'excluded')),
- reason TEXT,
- run_id TEXT,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- PRIMARY KEY (source, source_table, source_id)
-);
-
-CREATE TABLE IF NOT EXISTS vip_memberships (
- id TEXT PRIMARY KEY,
- member_subject TEXT NOT NULL,
- creator_id TEXT NOT NULL REFERENCES vip_creators(id),
- plan_id TEXT REFERENCES vip_plans(id),
- plan_version_id TEXT REFERENCES vip_plan_versions(id),
- billing_subscription_id TEXT,
- origin TEXT NOT NULL CHECK (origin IN ('checkout', 'billing', 'import')),
- joined_at TEXT NOT NULL,
- terms_since TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- UNIQUE (member_subject, creator_id)
-);
-CREATE INDEX IF NOT EXISTS idx_vip_memberships_creator ON vip_memberships (creator_id);
-
-CREATE TABLE IF NOT EXISTS vip_entitlement_projection (
- member_subject TEXT NOT NULL,
- creator_subject TEXT NOT NULL,
- kind TEXT NOT NULL,
- active INTEGER NOT NULL,
- expires_at TEXT,
- cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
- subscription_id TEXT,
- subscription_status TEXT,
- source TEXT NOT NULL CHECK (source IN ('event', 'billing_check', 'import')),
- last_reason TEXT,
- billing_event_id TEXT,
- billing_as_of INTEGER NOT NULL,
- synced_at INTEGER NOT NULL,
- valid_until INTEGER NOT NULL,
- last_used_at INTEGER,
- PRIMARY KEY (member_subject, creator_subject, kind)
-);
-CREATE INDEX IF NOT EXISTS idx_vip_projection_creator ON vip_entitlement_projection (creator_subject, kind, active);
-
-CREATE TABLE IF NOT EXISTS vip_checkouts (
- id TEXT PRIMARY KEY,
- member_subject TEXT NOT NULL,
- creator_id TEXT NOT NULL REFERENCES vip_creators(id),
- plan_id TEXT NOT NULL REFERENCES vip_plans(id),
- plan_version_id TEXT NOT NULL REFERENCES vip_plan_versions(id),
- provider TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('created', 'handed_off', 'paid', 'failed', 'used')),
- billing_intent_id TEXT,
- billing_subscription_id TEXT,
- checkout_url TEXT,
- checkout_ref TEXT,
- error TEXT,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
-);
-CREATE INDEX IF NOT EXISTS idx_vip_checkouts_member ON vip_checkouts (member_subject, creator_id, created_at);
-`;
-
-function openDb(dbPath) {
- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
- const db = new Database(dbPath);
- db.pragma('journal_mode = WAL');
- db.pragma('foreign_keys = ON');
- db.pragma('busy_timeout = 5000');
- migrate(db);
- return db;
-}
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
/**
/**
- * Columns added after the first schema, for new and existing databases alike (ALTER TABLE … ADD
- * COLUMN when missing; idempotent, never rewrites a row).
+ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
+ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle; they also seed
+ * the settings row and the network creator.
*/
*/
-const COLUMNS = [
- // The creator's choice: their member count on the public card and widget (server/domain/cards.js).
- ['vip_creators', 'show_member_count', 'INTEGER NOT NULL DEFAULT 1'],
-];
-
-function addColumns(db) {
- const have = new Map();
- for (const [table, name, def] of COLUMNS) {
- if (!have.has(table)) have.set(table, new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name)));
- if (!have.get(table).has(name)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`); have.get(table).add(name); }
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh vip)');
+ log.warn(`[VIP] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
+ fs.mkdirSync(DEV_PGLITE, { recursive: true });
+ const db = createDb({ pglite: DEV_PGLITE, service: 'vip', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
}
}
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'vip-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'vip', registry, log });
}
}
-function migrate(db) {
- db.exec(SCHEMA);
- db.transaction(() => addColumns(db))();
- const now = new Date().toISOString();
- db.prepare('INSERT OR IGNORE INTO settings (id, created_at) VALUES (1, ?)').run(now);
- // The network itself offers network-wide plans and owns network perks.
- db.prepare(`INSERT OR IGNORE INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)
- VALUES ('network', 'network', NULL, NULL, NULL, 'OpenVibe', 'system', ?, ?)`).run(now, now);
-}
-
-module.exports = { openDb, migrate, COLUMNS };
+module.exports = { openDb, MIGRATIONS };
@@ -59,17 +59,19 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
@@ -59,17 +59,19 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
return pending;
return pending;
}
}
- const kindOf = (perkId) => { const p = perks.byId(perkId); return p ? p.kind : 'other'; };
+ const kindOf = async (perkId) => { const p = await perks.byId(perkId); return p ? p.kind : 'other'; };
/** The published plans of a creator, each at the version new members buy. */
/** The published plans of a creator, each at the version new members buy. */
- function publicPlans(c) {
- return plans.list({ creatorId: c.id }).map((p) => plans.present(p)).filter((p) => p.current_version);
+ async function publicPlans(c) {
+ return (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p)))).filter((p) => p.current_version);
}
}
- function badge(c, list = publicPlans(c)) {
+ async function badge(c, list = null) {
+ if (!list) list = await publicPlans(c);
const plan = list.find((p) => p.purchasable) || list[0];
const plan = list.find((p) => p.purchasable) || list[0];
const v = plan && plan.current_version;
const v = plan && plan.current_version;
- const perk = v && v.perks.find((x) => kindOf(x.id) === 'badge');
+ const kinds = v ? await Promise.all(v.perks.map(async (x) => await kindOf(x.id))) : [];
+ const perk = v && v.perks.find((_, i) => kinds[i] === 'badge');
const label = perk ? perk.name.replace(BADGE_SUFFIX, '').trim() || perk.name : 'Member';
const label = perk ? perk.name.replace(BADGE_SUFFIX, '').trim() || perk.name : 'Member';
return { label: label.slice(0, 24) };
return { label: label.slice(0, 24) };
}
}
@@ -85,28 +87,28 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
@@ -85,28 +87,28 @@ function createCards({ db, config, creators, plans, perks, billing, now, log = c
/** The public card of a creator row, or null when it has none (no handle, suspended, no published plan). */
/** The public card of a creator row, or null when it has none (no handle, suspended, no published plan). */
async function card(c) {
async function card(c) {
if (!c || c.kind !== 'creator' || c.status !== 'active' || !c.username) return null;
if (!c || c.kind !== 'creator' || c.status !== 'active' || !c.username) return null;
- const list = publicPlans(c);
+ const list = await publicPlans(c);
if (!list.length) return null;
if (!list.length) return null;
const u = urls(c);
const u = urls(c);
const joiningOpen = config.billing.providers.length > 0 && list.some((p) => p.purchasable);
const joiningOpen = config.billing.providers.length > 0 && list.some((p) => p.purchasable);
- const [members, sub] = await Promise.all([memberCount(c), list.some((p) => p.purchasable) ? price() : null]);
+ const [members, sub] = await Promise.all([memberCount(c), list.some((p) => p.purchasable) ? await price() : null]);
return {
return {
creator: { username: c.username, display_name: c.display_name || c.username, url: u.page },
creator: { username: c.username, display_name: c.display_name || c.username, url: u.page },
- plans: list.map((p) => {
+ plans: (await Promise.all(list.map(async (p) => {
const v = p.current_version;
const v = p.current_version;
return {
return {
id: p.id, slug: p.slug, name: v.name, description: v.description, benefits: v.benefits, version: v.version,
id: p.id, slug: p.slug, name: v.name, description: v.description, benefits: v.benefits, version: v.version,
- perks: v.perks.map((x) => ({ key: x.key, name: x.name, kind: kindOf(x.id) })),
+ perks: (await Promise.all(v.perks.map(async (x) => ({ key: x.key, name: x.name, kind: await kindOf(x.id) })))),
purchasable: p.purchasable,
purchasable: p.purchasable,
join_url: `${u.page}#plan-${encodeURIComponent(p.slug)}`,
join_url: `${u.page}#plan-${encodeURIComponent(p.slug)}`,
terms_url: `${u.page}/plans/${encodeURIComponent(p.slug)}`,
terms_url: `${u.page}/plans/${encodeURIComponent(p.slug)}`,
};
};
- }),
+ }))),
join_url: u.page,
join_url: u.page,
joining_open: joiningOpen,
joining_open: joiningOpen,
price: sub ? { price_cents: sub.price_cents, period_days: sub.period_days, charged_by: 'OpenVibe.Billing' } : null,
price: sub ? { price_cents: sub.price_cents, period_days: sub.period_days, charged_by: 'OpenVibe.Billing' } : null,
members: { count: members.count, shown: members.shown, stale: members.stale },
members: { count: members.count, shown: members.shown, stale: members.stale },
- badge: { ...badge(c, list), svg_url: u.badge },
+ badge: { ...await badge(c, list), svg_url: u.badge },
widget_url: u.widget,
widget_url: u.widget,
card_url: u.card,
card_url: u.card,
};
};
@@ -29,13 +29,13 @@ function billingProblem(err) {
@@ -29,13 +29,13 @@ function billingProblem(err) {
}
}
function createCheckout({ db, now, config, billing, creators, plans, entitlements }) {
function createCheckout({ db, now, config, billing, creators, plans, entitlements }) {
- const byId = (id) => db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;
+ const byId = async (id) => await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(id) || null;
async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {
async function start({ member, planId, provider, successUrl, cancelUrl, autoRenew = true, traceparent }) {
- const plan = plans.byId(String(planId || ''));
+ const plan = await plans.byId(String(planId || ''));
if (!plan || plan.status !== 'published') fail(404, 'vip.plan_not_found', 'no published plan with that id');
if (!plan || plan.status !== 'published') fail(404, 'vip.plan_not_found', 'no published plan with that id');
if (!plan.billing_kind) fail(409, 'vip.checkout.unavailable', 'this plan is not sold through Billing yet');
if (!plan.billing_kind) fail(409, 'vip.checkout.unavailable', 'this plan is not sold through Billing yet');
- const creator = creators.byId(plan.creator_id);
+ const creator = await creators.byId(plan.creator_id);
if (!creator || creator.status !== 'active' || !creator.subject) fail(409, 'vip.checkout.unavailable', 'this creator is not taking new members');
if (!creator || creator.status !== 'active' || !creator.subject) fail(409, 'vip.checkout.unavailable', 'this creator is not taking new members');
if (creator.subject === member) fail(422, 'vip.checkout.self', 'you cannot join your own plan');
if (creator.subject === member) fail(422, 'vip.checkout.self', 'you cannot join your own plan');
const p = String(provider || '').toLowerCase();
const p = String(provider || '').toLowerCase();
@@ -50,27 +50,27 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -50,27 +50,27 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
const at = iso(now());
const at = iso(now());
const id = prefixedId('vco', now());
const id = prefixedId('vco', now());
- db.prepare(`INSERT INTO vip_checkouts (id, member_subject, creator_id, plan_id, plan_version_id, provider, status, created_at, updated_at)
+ await db.prepare(`INSERT INTO vip_checkouts (id, member_subject, creator_id, plan_id, plan_version_id, provider, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?)`).run(id, member, creator.id, plan.id, plan.current_version_id, p, at, at);
VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?)`).run(id, member, creator.id, plan.id, plan.current_version_id, p, at, at);
- const mark = (fields) => {
+ const mark = async (fields) => {
const cols = Object.keys(fields);
const cols = Object.keys(fields);
- db.prepare(`UPDATE vip_checkouts SET ${cols.map((c) => `${c} = @${c}`).join(', ')}, updated_at = @updated_at WHERE id = @id`).run({ ...fields, updated_at: iso(now()), id });
+ await db.prepare(`UPDATE vip_checkouts SET ${cols.map((c) => `${c} = @${c}`).join(', ')}, updated_at = @updated_at WHERE id = @id`).run({ ...fields, updated_at: iso(now()), id });
};
};
if (p === 'credit') {
if (p === 'credit') {
let out;
let out;
try {
try {
out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, autoRenew, key: `vip:checkout:${id}`, traceparent });
out = await billing.subscribeWithCredit({ subscriber: member, creator: creator.subject, autoRenew, key: `vip:checkout:${id}`, traceparent });
- } catch (e) { mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
+ } catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
const sub = out.subscription || {};
const sub = out.subscription || {};
const ent = out.entitlement || {};
const ent = out.entitlement || {};
- mark({ status: 'paid', billing_subscription_id: sub.id || null });
- entitlements.apply({
+ await mark({ status: 'paid', billing_subscription_id: sub.id || null });
+ await entitlements.apply({
member, creator: creator.subject, active: !!ent.active, expiresAt: ent.expires_at || sub.current_period_end || null,
member, creator: creator.subject, active: !!ent.active, expiresAt: ent.expires_at || sub.current_period_end || null,
cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id || null, subscriptionStatus: sub.status || null,
cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id || null, subscriptionStatus: sub.status || null,
reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, traceparent,
reason: 'granted', source: 'billing_check', asOf: now(), checkoutId: id, traceparent,
});
});
- return { checkout: present(byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };
+ return { checkout: present(await byId(id)), membership_started: !!ent.active, checkout_url: null, checkout_ref: null };
}
}
let out;
let out;
@@ -81,7 +81,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -81,7 +81,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,
cancelUrl: cancelUrl || `${config.baseUrl}/${encodeURIComponent(creator.username || '')}`,
key: `vip:checkout:${id}`, traceparent,
key: `vip:checkout:${id}`, traceparent,
});
});
- } catch (e) { mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
+ } catch (e) { await mark({ status: 'failed', error: String(e.code || e.message).slice(0, 200) }); billingProblem(e); }
const intent = out.intent || {};
const intent = out.intent || {};
let url = out.checkout_url || null;
let url = out.checkout_url || null;
if (!url && intent.checkout_ref && config.billing.powerchatLinkTemplate) {
if (!url && intent.checkout_ref && config.billing.powerchatLinkTemplate) {
@@ -89,13 +89,13 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -89,13 +89,13 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
}
}
// Only ever send a member to an http(s) page (never javascript:/data: from a bad answer).
// Only ever send a member to an http(s) page (never javascript:/data: from a bad answer).
if (url && !/^https?:\/\//i.test(url)) url = null;
if (url && !/^https?:\/\//i.test(url)) url = null;
- mark({ status: 'handed_off', billing_intent_id: intent.id || null, checkout_url: url, checkout_ref: intent.checkout_ref || null });
- return { checkout: present(byId(id)), membership_started: false, checkout_url: url, checkout_ref: intent.checkout_ref || null, amount_cents: intent.amount_cents || null };
+ await mark({ status: 'handed_off', billing_intent_id: intent.id || null, checkout_url: url, checkout_ref: intent.checkout_ref || null });
+ return { checkout: present(await byId(id)), membership_started: false, checkout_url: url, checkout_ref: intent.checkout_ref || null, amount_cents: intent.amount_cents || null };
}
}
/** Cancel at period end, through Billing. Only the member themself. */
/** Cancel at period end, through Billing. Only the member themself. */
async function cancel({ member, creatorId, traceparent }) {
async function cancel({ member, creatorId, traceparent }) {
- const creator = creators.byId(creatorId);
+ const creator = await creators.byId(creatorId);
if (!creator || !creator.subject) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
if (!creator || !creator.subject) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
let row;
let row;
try { row = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }
try { row = await entitlements.authoritative(member, creator.subject, { traceparent }); } catch (e) { billingProblem(e); }
@@ -112,7 +112,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
@@ -112,7 +112,7 @@ function createCheckout({ db, now, config, billing, creators, plans, entitlement
out = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent });
out = await billing.cancelSubscription({ id: sub.id, key: `vip:cancel:${sub.id}:${sub.current_period_end || 'open'}`, traceparent });
} catch (e) { billingProblem(e); }
} catch (e) { billingProblem(e); }
const s = (out && out.subscription) || {};
const s = (out && out.subscription) || {};
- entitlements.applyCanceled({
+ await entitlements.applyCanceled({
member, creator: creator.subject, subscriptionId: s.id || sub.id, subscriptionStatus: s.status || 'active',
member, creator: creator.subject, subscriptionId: s.id || sub.id, subscriptionStatus: s.status || 'active',
currentPeriodEnd: s.current_period_end || null, asOf: now(), traceparent, source: 'billing_check',
currentPeriodEnd: s.current_period_end || null, asOf: now(), traceparent, source: 'billing_check',
});
});
@@ -11,57 +11,57 @@ const { fail, prefixedId, iso, text } = require('../util');
@@ -11,57 +11,57 @@ const { fail, prefixedId, iso, text } = require('../util');
const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
function createCreators({ db, now }) {
function createCreators({ db, now }) {
- const byId = (id) => db.prepare('SELECT * FROM vip_creators WHERE id = ?').get(id) || null;
- const bySubject = (s) => db.prepare('SELECT * FROM vip_creators WHERE subject = ?').get(s) || null;
- const byUsername = (u) => (u ? db.prepare('SELECT * FROM vip_creators WHERE username_lc = ?').get(String(u).toLowerCase()) || null : null);
- const network = () => byId('network');
+ const byId = async (id) => await db.prepare('SELECT * FROM vip_creators WHERE id = ?').get(id) || null;
+ const bySubject = async (s) => await db.prepare('SELECT * FROM vip_creators WHERE subject = ?').get(s) || null;
+ const byUsername = async (u) => (u ? await db.prepare('SELECT * FROM vip_creators WHERE username_lc = ?').get(String(u).toLowerCase()) || null : null);
+ const network = async () => await byId('network');
/**
/**
* The creator row for a subject, created on first use. username/displayName (from the creator's
* The creator row for a subject, created on first use. username/displayName (from the creator's
* own Network token, the importer or the identity service) refresh the cached handle; a handle
* own Network token, the importer or the identity service) refresh the cached handle; a handle
* another row still holds (a rename) is taken over, the stale row keeps no handle.
* another row still holds (a rename) is taken over, the stale row keeps no handle.
*/
*/
- function ensure({ subject, username = null, displayName = null, origin = 'self' }) {
+ async function ensure({ subject, username = null, displayName = null, origin = 'self' }) {
if (!subject) fail(422, 'vip.invalid_subject', 'a creator needs a subject');
if (!subject) fail(422, 'vip.invalid_subject', 'a creator needs a subject');
const at = iso(now());
const at = iso(now());
const handle = username && USERNAME_RE.test(String(username)) ? String(username) : null;
const handle = username && USERNAME_RE.test(String(username)) ? String(username) : null;
- let row = bySubject(subject);
+ let row = await bySubject(subject);
if (handle) {
if (handle) {
- const holder = byUsername(handle);
- if (holder && holder.subject !== subject) db.prepare('UPDATE vip_creators SET username = NULL, username_lc = NULL, updated_at = ? WHERE id = ?').run(at, holder.id);
+ const holder = await byUsername(handle);
+ if (holder && holder.subject !== subject) await db.prepare('UPDATE vip_creators SET username = NULL, username_lc = NULL, updated_at = ? WHERE id = ?').run(at, holder.id);
}
}
if (!row) {
if (!row) {
const id = prefixedId('vcr', now());
const id = prefixedId('vcr', now());
- db.prepare(`INSERT INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)
+ await db.prepare(`INSERT INTO vip_creators (id, kind, subject, username, username_lc, display_name, origin, created_at, updated_at)
VALUES (?, 'creator', ?, ?, ?, ?, ?, ?, ?)`).run(id, subject, handle, handle ? handle.toLowerCase() : null, displayName ? String(displayName).slice(0, 80) : null, origin, at, at);
VALUES (?, 'creator', ?, ?, ?, ?, ?, ?, ?)`).run(id, subject, handle, handle ? handle.toLowerCase() : null, displayName ? String(displayName).slice(0, 80) : null, origin, at, at);
- return byId(id);
+ return await byId(id);
}
}
const changes = {};
const changes = {};
if (handle && handle !== row.username) changes.username = handle;
if (handle && handle !== row.username) changes.username = handle;
if (displayName && String(displayName).slice(0, 80) !== row.display_name) changes.display_name = String(displayName).slice(0, 80);
if (displayName && String(displayName).slice(0, 80) !== row.display_name) changes.display_name = String(displayName).slice(0, 80);
if (Object.keys(changes).length) {
if (Object.keys(changes).length) {
- db.prepare(`UPDATE vip_creators SET username = COALESCE(?, username), username_lc = COALESCE(?, username_lc), display_name = COALESCE(?, display_name), updated_at = ? WHERE id = ?`)
+ await db.prepare(`UPDATE vip_creators SET username = COALESCE(?, username), username_lc = COALESCE(?, username_lc), display_name = COALESCE(?, display_name), updated_at = ? WHERE id = ?`)
.run(changes.username || null, changes.username ? changes.username.toLowerCase() : null, changes.display_name || null, at, row.id);
.run(changes.username || null, changes.username ? changes.username.toLowerCase() : null, changes.display_name || null, at, row.id);
- row = byId(row.id);
+ row = await byId(row.id);
}
}
return row;
return row;
}
}
- function update(id, { displayName, bio, showMemberCount }) {
- const row = byId(id);
+ async function update(id, { displayName, bio, showMemberCount }) {
+ const row = await byId(id);
if (!row) fail(404, 'vip.creator_not_found', `no creator ${id}`);
if (!row) fail(404, 'vip.creator_not_found', `no creator ${id}`);
const name = displayName !== undefined ? text(displayName, 'display_name', 80) : row.display_name;
const name = displayName !== undefined ? text(displayName, 'display_name', 80) : row.display_name;
const about = bio !== undefined ? text(bio, 'bio', 2000) : row.bio;
const about = bio !== undefined ? text(bio, 'bio', 2000) : row.bio;
const count = showMemberCount !== undefined ? (showMemberCount ? 1 : 0) : row.show_member_count;
const count = showMemberCount !== undefined ? (showMemberCount ? 1 : 0) : row.show_member_count;
- db.prepare('UPDATE vip_creators SET display_name = ?, bio = ?, show_member_count = ?, updated_at = ? WHERE id = ?').run(name, about, count, iso(now()), id);
- return byId(id);
+ await db.prepare('UPDATE vip_creators SET display_name = ?, bio = ?, show_member_count = ?, updated_at = ? WHERE id = ?').run(name, about, count, iso(now()), id);
+ return await byId(id);
}
}
/** Creators with at least one published plan, for the directory and the sitemap. */
/** Creators with at least one published plan, for the directory and the sitemap. */
- function listPublic({ limit = 200 } = {}) {
- return db.prepare(`SELECT c.* FROM vip_creators c WHERE c.status = 'active' AND c.kind = 'creator' AND c.username IS NOT NULL
+ async function listPublic({ limit = 200 } = {}) {
+ return await db.prepare(`SELECT c.* FROM vip_creators c WHERE c.status = 'active' AND c.kind = 'creator' AND c.username IS NOT NULL
AND EXISTS (SELECT 1 FROM vip_plans p WHERE p.creator_id = c.id AND p.status = 'published')
AND EXISTS (SELECT 1 FROM vip_plans p WHERE p.creator_id = c.id AND p.status = 'published')
- ORDER BY COALESCE(c.display_name, c.username) COLLATE NOCASE LIMIT ?`).all(limit);
+ ORDER BY lower(COALESCE(c.display_name, c.username)) LIMIT ?`).all(limit);
}
}
function present(c) {
function present(c) {
@@ -24,7 +24,7 @@ const KIND = 'channel_subscription';
@@ -24,7 +24,7 @@ const KIND = 'channel_subscription';
function createEntitlements({ db, now, config, billing, outbox, memberships, plans, log = console }) {
function createEntitlements({ db, now, config, billing, outbox, memberships, plans, log = console }) {
const { maxAgeMs, graceMs } = config.projection;
const { maxAgeMs, graceMs } = config.projection;
- const getRow = (member, creator, kind = KIND) => db.prepare('SELECT * FROM vip_entitlement_projection WHERE member_subject = ? AND creator_subject = ? AND kind = ?').get(member, creator, kind) || null;
+ const getRow = async (member, creator, kind = KIND) => await db.prepare('SELECT * FROM vip_entitlement_projection WHERE member_subject = ? AND creator_subject = ? AND kind = ?').get(member, creator, kind) || null;
function validUntil(at, active, expiresAt) {
function validUntil(at, active, expiresAt) {
const cap = at + maxAgeMs;
const cap = at + maxAgeMs;
@@ -33,8 +33,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -33,8 +33,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
return Number.isFinite(end) ? Math.min(cap, end) : at;
return Number.isFinite(end) ? Math.min(cap, end) : at;
}
}
- function emitChanged(row, prev, { reason, membership }, traceparent) {
- const v = membership && membership.plan_version_id ? plans.version(membership.plan_version_id) : null;
+ async function emitChanged(row, prev, { reason, membership }, traceparent) {
+ const v = membership && membership.plan_version_id ? await plans.version(membership.plan_version_id) : null;
outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {
outbox.emit('vip.membership.changed', { type: 'membership', id: `${row.member_subject}:${row.creator_subject}` }, {
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
active: !!row.active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
@@ -52,14 +52,14 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -52,14 +52,14 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
* subscriptionStatus?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),
* subscriptionStatus?, reason, source: 'event'|'billing_check'|'import', eventId?, asOf (ms),
* origin?, checkoutId?, traceparent? }
* origin?, checkoutId?, traceparent? }
*/
*/
- function apply(input) {
- return db.transaction(() => {
+ async function apply(input) {
+ return await db.tx(async () => {
const kind = input.kind || KIND;
const kind = input.kind || KIND;
- const prev = getRow(input.member, input.creator, kind);
+ const prev = await getRow(input.member, input.creator, kind);
const at = now();
const at = now();
if (prev && input.asOf < prev.billing_as_of) {
if (prev && input.asOf < prev.billing_as_of) {
if (!input.active && prev.active) {
if (!input.active && prev.active) {
- db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(input.member, input.creator, kind);
+ await db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(input.member, input.creator, kind);
return { outcome: 'doubt', changed: false };
return { outcome: 'doubt', changed: false };
}
}
return { outcome: 'stale_ignored', changed: false };
return { outcome: 'stale_ignored', changed: false };
@@ -74,7 +74,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -74,7 +74,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
billing_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, input.active, input.expiresAt),
billing_as_of: input.asOf, synced_at: at, valid_until: validUntil(at, input.active, input.expiresAt),
last_used_at: prev ? prev.last_used_at : null,
last_used_at: prev ? prev.last_used_at : null,
};
};
- db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,
+ await db.prepare(`INSERT INTO vip_entitlement_projection (member_subject, creator_subject, kind, active, expires_at, cancel_at_period_end, subscription_id,
subscription_status, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)
subscription_status, source, last_reason, billing_event_id, billing_as_of, synced_at, valid_until, last_used_at)
VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status,
VALUES (@member_subject, @creator_subject, @kind, @active, @expires_at, @cancel_at_period_end, @subscription_id, @subscription_status,
@source, @last_reason, @billing_event_id, @billing_as_of, @synced_at, @valid_until, @last_used_at)
@source, @last_reason, @billing_event_id, @billing_as_of, @synced_at, @valid_until, @last_used_at)
@@ -83,43 +83,43 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -83,43 +83,43 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
subscription_status = excluded.subscription_status, source = excluded.source, last_reason = excluded.last_reason,
subscription_status = excluded.subscription_status, source = excluded.source, last_reason = excluded.last_reason,
billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,
billing_event_id = excluded.billing_event_id, billing_as_of = excluded.billing_as_of, synced_at = excluded.synced_at,
valid_until = excluded.valid_until`).run(next);
valid_until = excluded.valid_until`).run(next);
- const { membership } = memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });
+ const { membership } = await memberships.onProjection({ prev, next, reason: input.reason, origin: input.origin, checkoutId: input.checkoutId });
// A first look that finds no membership is not a change anyone needs to hear about.
// A first look that finds no membership is not a change anyone needs to hear about.
const changed = prev
const changed = prev
? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end)
? (!!prev.active !== !!next.active || prev.expires_at !== next.expires_at || !!prev.cancel_at_period_end !== !!next.cancel_at_period_end)
: !!next.active;
: !!next.active;
- if (changed) emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);
- return { outcome: changed ? 'changed' : 'unchanged', changed, row: getRow(input.member, input.creator, kind), membership };
- })();
+ if (changed) await emitChanged(next, prev, { reason: input.reason, membership }, input.traceparent);
+ return { outcome: changed ? 'changed' : 'unchanged', changed, row: await getRow(input.member, input.creator, kind), membership };
+ });
}
}
/** A cancellation scheduled at Billing: the period still runs; only the flag changes. */
/** A cancellation scheduled at Billing: the period still runs; only the flag changes. */
- function applyCanceled({ member, creator, subscriptionId, subscriptionStatus, currentPeriodEnd, eventId, asOf, traceparent, source = 'event' }) {
- return db.transaction(() => {
- const prev = getRow(member, creator);
+ async function applyCanceled({ member, creator, subscriptionId, subscriptionStatus, currentPeriodEnd, eventId, asOf, traceparent, source = 'event' }) {
+ return await db.tx(async () => {
+ const prev = await getRow(member, creator);
if (!prev) return { outcome: 'no_projection', changed: false };
if (!prev) return { outcome: 'no_projection', changed: false };
if (asOf < prev.billing_as_of) return { outcome: 'stale_ignored', changed: false };
if (asOf < prev.billing_as_of) return { outcome: 'stale_ignored', changed: false };
const active = subscriptionStatus === 'active' ? !!prev.active : false;
const active = subscriptionStatus === 'active' ? !!prev.active : false;
- return apply({
+ return await apply({
member, creator, active, expiresAt: active ? (prev.expires_at || currentPeriodEnd) : prev.expires_at, cancelAtPeriodEnd: true,
member, creator, active, expiresAt: active ? (prev.expires_at || currentPeriodEnd) : prev.expires_at, cancelAtPeriodEnd: true,
subscriptionId, subscriptionStatus, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,
subscriptionId, subscriptionStatus, reason: 'cancel_scheduled', source, eventId, asOf, traceparent,
});
});
- })();
+ });
}
}
/** Something happened that Billing will describe in detail (a refund): stop trusting the row. */
/** Something happened that Billing will describe in detail (a refund): stop trusting the row. */
- function doubt(member, creator, kind = KIND) {
- return db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(member, creator, kind).changes > 0;
+ async function doubt(member, creator, kind = KIND) {
+ return (await db.prepare('UPDATE vip_entitlement_projection SET valid_until = 0 WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(member, creator, kind)).changes > 0;
}
}
- function answer(row, source, { stale = false, membership } = {}) {
+ async function answer(row, source, { stale = false, membership } = {}) {
const t = now();
const t = now();
const active = !!row.active && !!row.expires_at && Date.parse(row.expires_at) > t;
const active = !!row.active && !!row.expires_at && Date.parse(row.expires_at) > t;
return {
return {
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
member: userRef(row.member_subject), creator: userRef(row.creator_subject), kind: row.kind,
status: active ? 'active' : 'inactive', active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
status: active ? 'active' : 'inactive', active, expires_at: row.expires_at, cancel_at_period_end: !!row.cancel_at_period_end,
source, stale, valid_until: iso(Math.max(0, row.valid_until)), checked_at: iso(t),
source, stale, valid_until: iso(Math.max(0, row.valid_until)), checked_at: iso(t),
- membership: membership ? memberships.present(membership) : null,
+ membership: membership ? await memberships.present(membership) : null,
};
};
}
}
function unknown(member, creator, reason) {
function unknown(member, creator, reason) {
@@ -129,8 +129,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -129,8 +129,8 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
};
};
}
}
- const membershipOf = (member, creatorSubject) => {
- const row = db.prepare('SELECT m.* FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?').get(member, creatorSubject);
+ const membershipOf = async (member, creatorSubject) => {
+ const row = await db.prepare('SELECT m.* FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?').get(member, creatorSubject);
return row || null;
return row || null;
};
};
@@ -138,12 +138,12 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -138,12 +138,12 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
async function authoritative(member, creator, { traceparent } = {}) {
async function authoritative(member, creator, { traceparent } = {}) {
const e = await billing.entitlement(member, creator);
const e = await billing.entitlement(member, creator);
const sub = e && e.subscription;
const sub = e && e.subscription;
- const out = apply({
+ const out = await apply({
member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,
member, creator, active: !!(e && e.active), expiresAt: (e && e.expires_at) || null,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
subscriptionStatus: sub ? sub.status : undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,
subscriptionStatus: sub ? sub.status : undefined, reason: 'checked', source: 'billing_check', asOf: now(), traceparent,
});
});
- return out.row || getRow(member, creator);
+ return out.row || await getRow(member, creator);
}
}
/**
/**
@@ -153,22 +153,22 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -153,22 +153,22 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
async function check(member, creator, { mode = 'auto', traceparent } = {}) {
async function check(member, creator, { mode = 'auto', traceparent } = {}) {
if (member === creator) return { ...unknown(member, creator, 'self'), status: 'inactive', reason: 'a creator is not a member of their own plans' };
if (member === creator) return { ...unknown(member, creator, 'self'), status: 'inactive', reason: 'a creator is not a member of their own plans' };
const t = now();
const t = now();
- let row = getRow(member, creator);
- if (row) db.prepare('UPDATE vip_entitlement_projection SET last_used_at = ? WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(t, member, creator, KIND);
+ let row = await getRow(member, creator);
+ if (row) await db.prepare('UPDATE vip_entitlement_projection SET last_used_at = ? WHERE member_subject = ? AND creator_subject = ? AND kind = ?').run(t, member, creator, KIND);
const fresh = row && t <= row.valid_until;
const fresh = row && t <= row.valid_until;
const withinGrace = row && t <= row.valid_until + graceMs;
const withinGrace = row && t <= row.valid_until + graceMs;
if (mode === 'projection') {
if (mode === 'projection') {
- if (fresh) return answer(row, 'projection', { membership: membershipOf(member, creator) });
- if (withinGrace) return answer(row, 'projection', { stale: true, membership: membershipOf(member, creator) });
+ if (fresh) return await answer(row, 'projection', { membership: await membershipOf(member, creator) });
+ if (withinGrace) return await answer(row, 'projection', { stale: true, membership: await membershipOf(member, creator) });
return unknown(member, creator, row ? 'projection_expired' : 'no_projection');
return unknown(member, creator, row ? 'projection_expired' : 'no_projection');
}
}
- if (mode === 'auto' && fresh) return answer(row, 'projection', { membership: membershipOf(member, creator) });
+ if (mode === 'auto' && fresh) return await answer(row, 'projection', { membership: await membershipOf(member, creator) });
try {
try {
row = await authoritative(member, creator, { traceparent });
row = await authoritative(member, creator, { traceparent });
- return answer(row, 'billing', { membership: membershipOf(member, creator) });
+ return await answer(row, 'billing', { membership: await membershipOf(member, creator) });
} catch (err) {
} catch (err) {
log.warn(`[VIP] authoritative entitlement check failed (${member} → ${creator}): ${err.message}`);
log.warn(`[VIP] authoritative entitlement check failed (${member} → ${creator}): ${err.message}`);
- if (mode === 'auto' && withinGrace) return { ...answer(row, 'projection', { stale: true, membership: membershipOf(member, creator) }), reason: 'billing_unavailable' };
+ if (mode === 'auto' && withinGrace) return { ...await answer(row, 'projection', { stale: true, membership: await membershipOf(member, creator) }), reason: 'billing_unavailable' };
return unknown(member, creator, 'billing_unavailable');
return unknown(member, creator, 'billing_unavailable');
}
}
}
}
@@ -179,7 +179,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -179,7 +179,7 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
*/
*/
async function refreshDue({ limit = 50 } = {}) {
async function refreshDue({ limit = 50 } = {}) {
const t = now();
const t = now();
- const rows = db.prepare(`SELECT member_subject, creator_subject FROM vip_entitlement_projection
+ const rows = await db.prepare(`SELECT member_subject, creator_subject FROM vip_entitlement_projection
WHERE kind = ? AND last_used_at >= ? AND valid_until <= ? ORDER BY valid_until LIMIT ?`).all(KIND, t - 3600_000, t + 120_000, limit);
WHERE kind = ? AND last_used_at >= ? AND valid_until <= ? ORDER BY valid_until LIMIT ?`).all(KIND, t - 3600_000, t + 120_000, limit);
let ok = 0; let failed = 0;
let ok = 0; let failed = 0;
for (const r of rows) {
for (const r of rows) {
@@ -189,9 +189,9 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
@@ -189,9 +189,9 @@ function createEntitlements({ db, now, config, billing, outbox, memberships, pla
}
}
/** Members of a creator according to the projection (fresh rows only; never past valid_until). */
/** Members of a creator according to the projection (fresh rows only; never past valid_until). */
- function projectedMembers(creator) {
+ async function projectedMembers(creator) {
const t = now();
const t = now();
- return db.prepare(`SELECT * FROM vip_entitlement_projection WHERE creator_subject = ? AND kind = ? AND active = 1 AND valid_until >= ? AND expires_at > ?
+ return await db.prepare(`SELECT * FROM vip_entitlement_projection WHERE creator_subject = ? AND kind = ? AND active = 1 AND valid_until >= ? AND expires_at > ?
ORDER BY expires_at DESC`).all(creator, KIND, t, iso(t));
ORDER BY expires_at DESC`).all(creator, KIND, t, iso(t));
}
}
@@ -19,7 +19,7 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
@@ -19,7 +19,7 @@ function createDomain({ db, config, outbox, billing, now = () => Date.now(), log
const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });
const policies = createPolicies({ db, now, creators, plans, perks, memberships, entitlements });
const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements });
const checkout = createCheckout({ db, now, config, billing, creators, plans, entitlements });
const cards = createCards({ db, config, creators, plans, perks, billing, now, log });
const cards = createCards({ db, config, creators, plans, perks, billing, now, log });
- const tx = (fn) => db.transaction(fn)();
+ const tx = async (fn) => await db.tx(fn);
return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout, cards };
return { db, config, now, tx, outbox, billing, creators, perks, plans, memberships, entitlements, policies, checkout, cards };
}
}
@@ -17,31 +17,31 @@ const { prefixedId, iso, json } = require('../util');
@@ -17,31 +17,31 @@ const { prefixedId, iso, json } = require('../util');
const CHECKOUT_WINDOW_MS = 14 * 86_400_000;
const CHECKOUT_WINDOW_MS = 14 * 86_400_000;
function createMemberships({ db, now, creators, plans }) {
function createMemberships({ db, now, creators, plans }) {
- const get = (member, creatorId) => db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? AND creator_id = ?').get(member, creatorId) || null;
- const byId = (id) => db.prepare('SELECT * FROM vip_memberships WHERE id = ?').get(id) || null;
+ const get = async (member, creatorId) => await db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? AND creator_id = ?').get(member, creatorId) || null;
+ const byId = async (id) => await db.prepare('SELECT * FROM vip_memberships WHERE id = ?').get(id) || null;
- function pendingCheckout(member, creatorId) {
+ async function pendingCheckout(member, creatorId) {
const since = iso(now() - CHECKOUT_WINDOW_MS);
const since = iso(now() - CHECKOUT_WINDOW_MS);
- return db.prepare(`SELECT * FROM vip_checkouts WHERE member_subject = ? AND creator_id = ? AND status IN ('handed_off', 'paid')
+ return await db.prepare(`SELECT * FROM vip_checkouts WHERE member_subject = ? AND creator_id = ? AND status IN ('handed_off', 'paid')
AND created_at >= ? ORDER BY created_at DESC LIMIT 1`).get(member, creatorId, since) || null;
AND created_at >= ? ORDER BY created_at DESC LIMIT 1`).get(member, creatorId, since) || null;
}
}
/** Record the version a membership is under. Inside the projection's transaction. */
/** Record the version a membership is under. Inside the projection's transaction. */
- function onProjection({ prev, next, reason, origin = null, checkoutId = null }) {
- const creator = creators.bySubject(next.creator_subject) || creators.ensure({ subject: next.creator_subject, origin: 'system' });
+ async function onProjection({ prev, next, reason, origin = null, checkoutId = null }) {
+ const creator = await creators.bySubject(next.creator_subject) || await creators.ensure({ subject: next.creator_subject, origin: 'system' });
const member = next.member_subject;
const member = next.member_subject;
- let m = get(member, creator.id);
+ let m = await get(member, creator.id);
const at = iso(now());
const at = iso(now());
if (!next.active) return { membership: m, creator };
if (!next.active) return { membership: m, creator };
// A membership that exists keeps its version unless this is a NEW purchase after a lapse:
// A membership that exists keeps its version unless this is a NEW purchase after a lapse:
// Billing says 'granted' after we saw it inactive, or the member's own checkout settled.
// Billing says 'granted' after we saw it inactive, or the member's own checkout settled.
- const pending = checkoutId ? null : pendingCheckout(member, creator.id);
+ const pending = checkoutId ? null : await pendingCheckout(member, creator.id);
const lapsed = prev && !prev.active;
const lapsed = prev && !prev.active;
const newPurchase = !!checkoutId || (lapsed && (reason === 'granted' || (reason !== 'renewed' && !!pending)));
const newPurchase = !!checkoutId || (lapsed && (reason === 'granted' || (reason !== 'renewed' && !!pending)));
if (m && !newPurchase) {
if (m && !newPurchase) {
if (next.subscription_id && next.subscription_id !== m.billing_subscription_id) {
if (next.subscription_id && next.subscription_id !== m.billing_subscription_id) {
- db.prepare('UPDATE vip_memberships SET billing_subscription_id = ?, updated_at = ? WHERE id = ?').run(next.subscription_id, at, m.id);
- m = get(member, creator.id);
+ await db.prepare('UPDATE vip_memberships SET billing_subscription_id = ?, updated_at = ? WHERE id = ?').run(next.subscription_id, at, m.id);
+ m = await get(member, creator.id);
}
}
return { membership: m, creator };
return { membership: m, creator };
}
}
@@ -49,54 +49,54 @@ function createMemberships({ db, now, creators, plans }) {
@@ -49,54 +49,54 @@ function createMemberships({ db, now, creators, plans }) {
let planId = null;
let planId = null;
let versionId = null;
let versionId = null;
let how = origin || 'billing';
let how = origin || 'billing';
- const checkout = checkoutId ? db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(checkoutId) : pending;
+ const checkout = checkoutId ? await db.prepare('SELECT * FROM vip_checkouts WHERE id = ?').get(checkoutId) : pending;
if (checkout) {
if (checkout) {
planId = checkout.plan_id; versionId = checkout.plan_version_id; how = 'checkout';
planId = checkout.plan_id; versionId = checkout.plan_version_id; how = 'checkout';
- db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);
+ await db.prepare("UPDATE vip_checkouts SET status = 'used', billing_subscription_id = COALESCE(billing_subscription_id, ?), updated_at = ? WHERE id = ?").run(next.subscription_id || null, at, checkout.id);
} else {
} else {
- const plan = plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind);
+ const plan = await plans.purchasable(creator.id, next.kind === 'channel_subscription' ? 'channel_subscription' : next.kind);
if (plan) { planId = plan.id; versionId = plan.current_version_id; }
if (plan) { planId = plan.id; versionId = plan.current_version_id; }
}
}
if (m) {
if (m) {
- db.prepare(`UPDATE vip_memberships SET plan_id = ?, plan_version_id = ?, billing_subscription_id = COALESCE(?, billing_subscription_id), origin = ?,
+ await db.prepare(`UPDATE vip_memberships SET plan_id = ?, plan_version_id = ?, billing_subscription_id = COALESCE(?, billing_subscription_id), origin = ?,
terms_since = ?, updated_at = ? WHERE id = ?`).run(planId, versionId, next.subscription_id || null, how, at, at, m.id);
terms_since = ?, updated_at = ? WHERE id = ?`).run(planId, versionId, next.subscription_id || null, how, at, at, m.id);
} else {
} else {
- db.prepare(`INSERT INTO vip_memberships (id, member_subject, creator_id, plan_id, plan_version_id, billing_subscription_id, origin, joined_at, terms_since, updated_at)
+ await db.prepare(`INSERT INTO vip_memberships (id, member_subject, creator_id, plan_id, plan_version_id, billing_subscription_id, origin, joined_at, terms_since, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prefixedId('vms', now()), member, creator.id, planId, versionId, next.subscription_id || null, how, at, at, at);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prefixedId('vms', now()), member, creator.id, planId, versionId, next.subscription_id || null, how, at, at, at);
}
}
- return { membership: get(member, creator.id), creator };
+ return { membership: await get(member, creator.id), creator };
}
}
- function preferences(member, creatorId) {
- const p = db.prepare('SELECT * FROM vip_member_preferences WHERE member_subject = ? AND creator_id = ?').get(member, creatorId);
+ async function preferences(member, creatorId) {
+ const p = await db.prepare('SELECT * FROM vip_member_preferences WHERE member_subject = ? AND creator_id = ?').get(member, creatorId);
return { show_badge: p ? !!p.show_badge : true, listed: p ? !!p.listed : false };
return { show_badge: p ? !!p.show_badge : true, listed: p ? !!p.listed : false };
}
}
- function setPreferences(member, creatorId, { showBadge, listed }) {
- const cur = preferences(member, creatorId);
+ async function setPreferences(member, creatorId, { showBadge, listed }) {
+ const cur = await preferences(member, creatorId);
const sb = showBadge === undefined ? cur.show_badge : !!showBadge;
const sb = showBadge === undefined ? cur.show_badge : !!showBadge;
const li = listed === undefined ? cur.listed : !!listed;
const li = listed === undefined ? cur.listed : !!listed;
- db.prepare(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES (?, ?, ?, ?, ?)
+ await db.prepare(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (member_subject, creator_id) DO UPDATE SET show_badge = excluded.show_badge, listed = excluded.listed, updated_at = excluded.updated_at`)
ON CONFLICT (member_subject, creator_id) DO UPDATE SET show_badge = excluded.show_badge, listed = excluded.listed, updated_at = excluded.updated_at`)
.run(member, creatorId, sb ? 1 : 0, li ? 1 : 0, iso(now()));
.run(member, creatorId, sb ? 1 : 0, li ? 1 : 0, iso(now()));
- return preferences(member, creatorId);
+ return await preferences(member, creatorId);
}
}
- const forMember = (member) => db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? ORDER BY joined_at DESC').all(member);
- const forCreator = (creatorId) => db.prepare('SELECT * FROM vip_memberships WHERE creator_id = ? ORDER BY joined_at DESC').all(creatorId);
+ const forMember = async (member) => await db.prepare('SELECT * FROM vip_memberships WHERE member_subject = ? ORDER BY joined_at DESC').all(member);
+ const forCreator = async (creatorId) => await db.prepare('SELECT * FROM vip_memberships WHERE creator_id = ? ORDER BY joined_at DESC').all(creatorId);
/** The perk keys a membership's version grants (its own snapshot, never the plan's latest). */
/** The perk keys a membership's version grants (its own snapshot, never the plan's latest). */
- function perkKeys(m) {
+ async function perkKeys(m) {
if (!m || !m.plan_version_id) return [];
if (!m || !m.plan_version_id) return [];
- return plans.versionPerks(m.plan_version_id).map((p) => p.perk_key);
+ return (await plans.versionPerks(m.plan_version_id)).map((p) => p.perk_key);
}
}
- function present(m) {
+ async function present(m) {
if (!m) return null;
if (!m) return null;
- const v = m.plan_version_id ? plans.version(m.plan_version_id) : null;
+ const v = m.plan_version_id ? await plans.version(m.plan_version_id) : null;
return {
return {
id: m.id, member: { type: 'user', id: m.member_subject }, creator_id: m.creator_id,
id: m.id, member: { type: 'user', id: m.member_subject }, creator_id: m.creator_id,
plan_id: m.plan_id, plan_version: v ? { id: v.id, version: v.version, name: v.name, terms: json(v.terms, {}) } : null,
plan_id: m.plan_id, plan_version: v ? { id: v.id, version: v.version, name: v.name, terms: json(v.terms, {}) } : null,
- perks: perkKeys(m), origin: m.origin, joined_at: m.joined_at, terms_since: m.terms_since,
+ perks: await perkKeys(m), origin: m.origin, joined_at: m.joined_at, terms_since: m.terms_since,
};
};
}
}
@@ -15,8 +15,8 @@ const PRODUCT_RE = /^[a-z][a-z0-9-]{1,39}$/;
@@ -15,8 +15,8 @@ const PRODUCT_RE = /^[a-z][a-z0-9-]{1,39}$/;
const BINDING_RE = /^[a-z][a-z0-9_.]{1,63}$/;
const BINDING_RE = /^[a-z][a-z0-9_.]{1,63}$/;
function createPerks({ db, now }) {
function createPerks({ db, now }) {
- const byId = (id) => db.prepare('SELECT * FROM vip_perks WHERE id = ?').get(id) || null;
- const byKey = (creatorId, key) => db.prepare('SELECT * FROM vip_perks WHERE creator_id = ? AND key = ?').get(creatorId, key) || null;
+ const byId = async (id) => await db.prepare('SELECT * FROM vip_perks WHERE id = ?').get(id) || null;
+ const byKey = async (creatorId, key) => await db.prepare('SELECT * FROM vip_perks WHERE creator_id = ? AND key = ?').get(creatorId, key) || null;
function cleanBindings(list) {
function cleanBindings(list) {
if (list == null) return null;
if (list == null) return null;
@@ -34,65 +34,65 @@ function createPerks({ db, now }) {
@@ -34,65 +34,65 @@ function createPerks({ db, now }) {
});
});
}
}
- function setBindings(perkId, list) {
+ async function setBindings(perkId, list) {
const at = iso(now());
const at = iso(now());
const keep = new Set(list.map((b) => `${b.product}:${b.binding}`));
const keep = new Set(list.map((b) => `${b.product}:${b.binding}`));
- for (const b of db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active'").all(perkId)) {
- if (!keep.has(`${b.product}:${b.binding}`)) db.prepare("UPDATE vip_product_bindings SET status = 'removed', updated_at = ? WHERE id = ?").run(at, b.id);
+ for (const b of await db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active'").all(perkId)) {
+ if (!keep.has(`${b.product}:${b.binding}`)) await db.prepare("UPDATE vip_product_bindings SET status = 'removed', updated_at = ? WHERE id = ?").run(at, b.id);
}
}
const up = db.prepare(`INSERT INTO vip_product_bindings (id, perk_id, product, binding, config, status, created_at, updated_at)
const up = db.prepare(`INSERT INTO vip_product_bindings (id, perk_id, product, binding, config, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
ON CONFLICT (perk_id, product, binding) DO UPDATE SET config = excluded.config, status = 'active', updated_at = excluded.updated_at`);
ON CONFLICT (perk_id, product, binding) DO UPDATE SET config = excluded.config, status = 'active', updated_at = excluded.updated_at`);
- for (const b of list) up.run(prefixedId('vpb', now()), perkId, b.product, b.binding, JSON.stringify(b.config), at, at);
+ for (const b of list) await up.run(prefixedId('vpb', now()), perkId, b.product, b.binding, JSON.stringify(b.config), at, at);
}
}
- function create({ creatorId, key, name, description, kind = 'other', bindings = [], actor = null }) {
+ async function create({ creatorId, key, name, description, kind = 'other', bindings = [], actor = null }) {
const k = slug(key || name, 'key');
const k = slug(key || name, 'key');
- if (byKey(creatorId, k)) fail(409, 'vip.perk_exists', `a perk with key ${k} already exists`);
+ if (await byKey(creatorId, k)) fail(409, 'vip.perk_exists', `a perk with key ${k} already exists`);
if (!KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);
if (!KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);
const clean = cleanBindings(bindings) || [];
const clean = cleanBindings(bindings) || [];
const at = iso(now());
const at = iso(now());
const id = prefixedId('vpk', now());
const id = prefixedId('vpk', now());
- db.transaction(() => {
- db.prepare(`INSERT INTO vip_perks (id, creator_id, key, name, description, kind, status, created_by, created_at, updated_at)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO vip_perks (id, creator_id, key, name, description, kind, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, k, text(name, 'name', 80, { required: true }), text(description, 'description', 500), kind, actor, at, at);
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, k, text(name, 'name', 80, { required: true }), text(description, 'description', 500), kind, actor, at, at);
- setBindings(id, clean);
- })();
- return byId(id);
+ await setBindings(id, clean);
+ });
+ return await byId(id);
}
}
- function update(perkId, { name, description, kind, status, bindings }) {
- const p = byId(perkId);
+ async function update(perkId, { name, description, kind, status, bindings }) {
+ const p = await byId(perkId);
if (!p) fail(404, 'vip.perk_not_found', `no perk ${perkId}`);
if (!p) fail(404, 'vip.perk_not_found', `no perk ${perkId}`);
if (kind !== undefined && !KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);
if (kind !== undefined && !KINDS.includes(kind)) fail(422, 'vip.invalid_input', `kind must be one of ${KINDS.join(', ')}`);
if (status !== undefined && !['active', 'retired'].includes(status)) fail(422, 'vip.invalid_input', 'status must be active or retired');
if (status !== undefined && !['active', 'retired'].includes(status)) fail(422, 'vip.invalid_input', 'status must be active or retired');
const clean = cleanBindings(bindings);
const clean = cleanBindings(bindings);
- db.transaction(() => {
- db.prepare('UPDATE vip_perks SET name = ?, description = ?, kind = ?, status = ?, updated_at = ? WHERE id = ?').run(
+ await db.tx(async () => {
+ await db.prepare('UPDATE vip_perks SET name = ?, description = ?, kind = ?, status = ?, updated_at = ? WHERE id = ?').run(
name !== undefined ? text(name, 'name', 80, { required: true }) : p.name,
name !== undefined ? text(name, 'name', 80, { required: true }) : p.name,
description !== undefined ? text(description, 'description', 500) : p.description,
description !== undefined ? text(description, 'description', 500) : p.description,
kind !== undefined ? kind : p.kind, status !== undefined ? status : p.status, iso(now()), perkId);
kind !== undefined ? kind : p.kind, status !== undefined ? status : p.status, iso(now()), perkId);
- if (clean) setBindings(perkId, clean);
- })();
- return byId(perkId);
+ if (clean) await setBindings(perkId, clean);
+ });
+ return await byId(perkId);
}
}
/** Perks usable in a creator's plans: their own plus the network's. */
/** Perks usable in a creator's plans: their own plus the network's. */
- function list({ creatorId, includeNetwork = true, includeRetired = false }) {
+ async function list({ creatorId, includeNetwork = true, includeRetired = false }) {
const owners = includeNetwork && creatorId !== 'network' ? [creatorId, 'network'] : [creatorId];
const owners = includeNetwork && creatorId !== 'network' ? [creatorId, 'network'] : [creatorId];
- return db.prepare(`SELECT * FROM vip_perks WHERE creator_id IN (${owners.map(() => '?').join(',')}) ${includeRetired ? '' : "AND status = 'active'"}
- ORDER BY creator_id = 'network', name COLLATE NOCASE`).all(...owners);
+ return await db.prepare(`SELECT * FROM vip_perks WHERE creator_id IN (${owners.map(() => '?').join(',')}) ${includeRetired ? '' : "AND status = 'active'"}
+ ORDER BY creator_id = 'network', lower(name)`).all(...owners);
}
}
/** Resolve perk ids or keys for a plan of creatorId: own or network perks, active only. */
/** Resolve perk ids or keys for a plan of creatorId: own or network perks, active only. */
- function resolveForPlan(creatorId, refs) {
+ async function resolveForPlan(creatorId, refs) {
if (refs == null) return null;
if (refs == null) return null;
if (!Array.isArray(refs) || refs.length > 30) fail(422, 'vip.invalid_input', 'perks must be a list of at most 30 perk ids or keys');
if (!Array.isArray(refs) || refs.length > 30) fail(422, 'vip.invalid_input', 'perks must be a list of at most 30 perk ids or keys');
const out = [];
const out = [];
const seen = new Set();
const seen = new Set();
for (const r of refs) {
for (const r of refs) {
const s = String(r || '');
const s = String(r || '');
- let p = s.startsWith('vpk_') ? byId(s) : (byKey(creatorId, s) || byKey('network', s));
+ let p = s.startsWith('vpk_') ? await byId(s) : (await byKey(creatorId, s) || await byKey('network', s));
if (p && p.creator_id !== creatorId && p.creator_id !== 'network') p = null;
if (p && p.creator_id !== creatorId && p.creator_id !== 'network') p = null;
if (!p || p.status !== 'active') fail(422, 'vip.perk_not_found', `no active perk ${s} for this creator`);
if (!p || p.status !== 'active') fail(422, 'vip.perk_not_found', `no active perk ${s} for this creator`);
if (!seen.has(p.id)) { seen.add(p.id); out.push(p); }
if (!seen.has(p.id)) { seen.add(p.id); out.push(p); }
@@ -100,7 +100,7 @@ function createPerks({ db, now }) {
@@ -100,7 +100,7 @@ function createPerks({ db, now }) {
return out;
return out;
}
}
- const bindingsOf = (perkId) => db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);
+ const bindingsOf = async (perkId) => await db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);
/**
/**
* The perks a presented membership's plan version grants, with their active bindings for one
* The perks a presented membership's plan version grants, with their active bindings for one
@@ -108,24 +108,24 @@ function createPerks({ db, now }) {
@@ -108,24 +108,24 @@ function createPerks({ db, now }) {
* binding are left out). The version's snapshot names the keys; a key resolves to the creator's
* binding are left out). The version's snapshot names the keys; a key resolves to the creator's
* own perk first, then the network's.
* own perk first, then the network's.
*/
*/
- function forMembership(membership, product) {
+ async function forMembership(membership, product) {
if (!membership || !Array.isArray(membership.perks) || !membership.perks.length) return [];
if (!membership || !Array.isArray(membership.perks) || !membership.perks.length) return [];
const out = [];
const out = [];
for (const key of membership.perks) {
for (const key of membership.perks) {
- const p = byKey(membership.creator_id, key) || byKey('network', key);
+ const p = await byKey(membership.creator_id, key) || await byKey('network', key);
if (!p || p.status !== 'active') continue;
if (!p || p.status !== 'active') continue;
- const bindings = bindingsOf(p.id).filter((b) => b.product === product).map((b) => ({ binding: b.binding, config: json(b.config, {}) }));
+ const bindings = (await bindingsOf(p.id)).filter((b) => b.product === product).map((b) => ({ binding: b.binding, config: json(b.config, {}) }));
if (bindings.length) out.push({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator', bindings });
if (bindings.length) out.push({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator', bindings });
}
}
return out;
return out;
}
}
- function present(p, { withBindings = true } = {}) {
+ async function present(p, { withBindings = true } = {}) {
if (!p) return null;
if (!p) return null;
return {
return {
id: p.id, key: p.key, name: p.name, description: p.description || null, kind: p.kind, status: p.status,
id: p.id, key: p.key, name: p.name, description: p.description || null, kind: p.kind, status: p.status,
scope: p.creator_id === 'network' ? 'network' : 'creator', creator_id: p.creator_id,
scope: p.creator_id === 'network' ? 'network' : 'creator', creator_id: p.creator_id,
- bindings: withBindings ? bindingsOf(p.id).map((b) => ({ product: b.product, binding: b.binding, config: json(b.config, {}) })) : undefined,
+ bindings: withBindings ? (await bindingsOf(p.id)).map((b) => ({ product: b.product, binding: b.binding, config: json(b.config, {}) })) : undefined,
updated_at: p.updated_at,
updated_at: p.updated_at,
};
};
}
}
@@ -20,11 +20,11 @@ const { fail, prefixedId, iso, text, slug, slugify, json } = require('../util');
@@ -20,11 +20,11 @@ const { fail, prefixedId, iso, text, slug, slugify, json } = require('../util');
const BILLING_KINDS = ['channel_subscription'];
const BILLING_KINDS = ['channel_subscription'];
function createPlans({ db, now, outbox, creators, perks }) {
function createPlans({ db, now, outbox, creators, perks }) {
- const byId = (id) => db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;
- const version = (id) => db.prepare('SELECT * FROM vip_plan_versions WHERE id = ?').get(id) || null;
- const latestVersion = (planId) => db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC LIMIT 1').get(planId) || null;
- const versions = (planId) => db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC').all(planId);
- const versionPerks = (versionId) => db.prepare('SELECT * FROM vip_plan_perks WHERE plan_version_id = ? ORDER BY position').all(versionId);
+ const byId = async (id) => await db.prepare('SELECT * FROM vip_plans WHERE id = ?').get(id) || null;
+ const version = async (id) => await db.prepare('SELECT * FROM vip_plan_versions WHERE id = ?').get(id) || null;
+ const latestVersion = async (planId) => await db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC LIMIT 1').get(planId) || null;
+ const versions = async (planId) => await db.prepare('SELECT * FROM vip_plan_versions WHERE plan_id = ? ORDER BY version DESC').all(planId);
+ const versionPerks = async (versionId) => await db.prepare('SELECT * FROM vip_plan_perks WHERE plan_version_id = ? ORDER BY position').all(versionId);
function cleanBenefits(v) {
function cleanBenefits(v) {
if (v == null) return null;
if (v == null) return null;
@@ -35,24 +35,24 @@ function createPlans({ db, now, outbox, creators, perks }) {
@@ -35,24 +35,24 @@ function createPlans({ db, now, outbox, creators, perks }) {
}
}
/** Insert version N+1 of a plan from complete terms. Inside a transaction. */
/** Insert version N+1 of a plan from complete terms. Inside a transaction. */
- function insertVersion(plan, { name, description, benefits, perkRows, changeNote, actor, publish }) {
+ async function insertVersion(plan, { name, description, benefits, perkRows, changeNote, actor, publish }) {
const n = plan.latest_version + 1;
const n = plan.latest_version + 1;
const at = iso(now());
const at = iso(now());
const id = prefixedId('vpv', now());
const id = prefixedId('vpv', now());
const perkSnap = perkRows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator' }));
const perkSnap = perkRows.map((p) => ({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator' }));
const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };
const terms = { name, description, benefits, perks: perkSnap, billing_kind: plan.billing_kind, price: plan.billing_kind ? 'set and charged by OpenVibe.Billing' : null };
terms.digest = crypto.createHash('sha256').update(JSON.stringify([terms.name, terms.description, terms.benefits, terms.perks, terms.billing_kind])).digest('hex').slice(0, 16);
terms.digest = crypto.createHash('sha256').update(JSON.stringify([terms.name, terms.description, terms.benefits, terms.perks, terms.billing_kind])).digest('hex').slice(0, 16);
- db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)
+ await db.prepare(`INSERT INTO vip_plan_versions (id, plan_id, version, name, description, benefits, terms, change_note, created_by, created_at, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, plan.id, n, name, description, JSON.stringify(benefits), JSON.stringify(terms), changeNote || null, actor, at, publish ? at : null);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, plan.id, n, name, description, JSON.stringify(benefits), JSON.stringify(terms), changeNote || null, actor, at, publish ? at : null);
const ins = db.prepare('INSERT INTO vip_plan_perks (plan_version_id, perk_id, perk_key, perk_name, position) VALUES (?, ?, ?, ?, ?)');
const ins = db.prepare('INSERT INTO vip_plan_perks (plan_version_id, perk_id, perk_key, perk_name, position) VALUES (?, ?, ?, ?, ?)');
- perkRows.forEach((p, i) => ins.run(id, p.id, p.key, p.name, i));
- db.prepare(`UPDATE vip_plans SET latest_version = ?, current_version_id = CASE WHEN ? THEN ? ELSE current_version_id END, updated_at = ? WHERE id = ?`)
+ for (const [i, p] of perkRows.entries()) await ins.run(id, p.id, p.key, p.name, i);
+ await db.prepare(`UPDATE vip_plans SET latest_version = ?, current_version_id = CASE WHEN ? THEN ? ELSE current_version_id END, updated_at = ? WHERE id = ?`)
.run(n, publish ? 1 : 0, id, at, plan.id);
.run(n, publish ? 1 : 0, id, at, plan.id);
- return version(id);
+ return await version(id);
}
}
- function emitPublished(plan, v, traceparent) {
- const creator = creators.byId(plan.creator_id);
+ async function emitPublished(plan, v, traceparent) {
+ const creator = await creators.byId(plan.creator_id);
outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {
outbox.emit('vip.plan.published', { type: 'plan', id: plan.id, revision: v.version }, {
plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,
plan_id: plan.id, slug: plan.slug, version: v.version, version_id: v.id,
creator: creators.present(creator), billing_kind: plan.billing_kind,
creator: creators.present(creator), billing_kind: plan.billing_kind,
@@ -61,117 +61,118 @@ function createPlans({ db, now, outbox, creators, perks }) {
@@ -61,117 +61,118 @@ function createPlans({ db, now, outbox, creators, perks }) {
}
}
function publishConflict(e) {
function publishConflict(e) {
- if (e && /UNIQUE constraint failed: vip_plans\.creator_id, vip_plans\.billing_kind/.test(e.message)) {
+ // PostgreSQL unique_violation (23505) on the one-published-plan-per-billing-kind index of vip_plans.
+ if (e && e.code === '23505' && e.table === 'vip_plans' && /\(creator_id, billing_kind\)/.test(e.detail || '')) {
fail(409, 'vip.plan.billing_product_taken', 'this creator already has a published plan sold as a channel subscription; Billing sells one per member and creator today, so archive the other plan first');
fail(409, 'vip.plan.billing_product_taken', 'this creator already has a published plan sold as a channel subscription; Billing sells one per member and creator today, so archive the other plan first');
}
}
throw e;
throw e;
}
}
- function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, publish = false, changeNote, actor = null, traceparent }) {
- const creator = creators.byId(creatorId);
+ async function create({ creatorId, slug: s, name, description, benefits, perks: perkRefs, billingKind, publish = false, changeNote, actor = null, traceparent }) {
+ const creator = await creators.byId(creatorId);
if (!creator) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
if (!creator) fail(404, 'vip.creator_not_found', `no creator ${creatorId}`);
const kind = billingKind === undefined ? (creator.kind === 'network' ? null : 'channel_subscription') : (billingKind || null);
const kind = billingKind === undefined ? (creator.kind === 'network' ? null : 'channel_subscription') : (billingKind || null);
if (kind && !BILLING_KINDS.includes(kind)) fail(422, 'vip.invalid_input', `billing_kind must be one of ${BILLING_KINDS.join(', ')} or null`);
if (kind && !BILLING_KINDS.includes(kind)) fail(422, 'vip.invalid_input', `billing_kind must be one of ${BILLING_KINDS.join(', ')} or null`);
if (kind && creator.kind === 'network') fail(422, 'vip.invalid_input', 'Billing has no network membership product yet; a network plan has billing_kind null');
if (kind && creator.kind === 'network') fail(422, 'vip.invalid_input', 'Billing has no network membership product yet; a network plan has billing_kind null');
const cleanName = text(name, 'name', 80, { required: true });
const cleanName = text(name, 'name', 80, { required: true });
const planSlug = s ? slug(s) : slugify(cleanName);
const planSlug = s ? slug(s) : slugify(cleanName);
- if (db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);
- const perkRows = perks.resolveForPlan(creatorId, perkRefs || []);
+ if (await db.prepare('SELECT 1 FROM vip_plans WHERE creator_id = ? AND slug = ?').get(creatorId, planSlug)) fail(409, 'vip.plan_exists', `a plan ${planSlug} already exists`);
+ const perkRows = await perks.resolveForPlan(creatorId, perkRefs || []);
const at = iso(now());
const at = iso(now());
const id = prefixedId('vpl', now());
const id = prefixedId('vpl', now());
try {
try {
- return db.transaction(() => {
- db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, current_version_id, latest_version, created_by, created_at, updated_at)
+ return await db.tx(async () => {
+ await db.prepare(`INSERT INTO vip_plans (id, creator_id, slug, status, billing_kind, current_version_id, latest_version, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, actor, at, at);
VALUES (?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(id, creatorId, planSlug, publish ? 'published' : 'draft', kind, actor, at, at);
- const v = insertVersion(byId(id), {
+ const v = await insertVersion(await byId(id), {
name: cleanName, description: text(description, 'description', 2000), benefits: cleanBenefits(benefits) || [],
name: cleanName, description: text(description, 'description', 2000), benefits: cleanBenefits(benefits) || [],
perkRows, changeNote: changeNote || 'created', actor, publish,
perkRows, changeNote: changeNote || 'created', actor, publish,
});
});
- if (publish) emitPublished(byId(id), v, traceparent);
- return byId(id);
- })();
+ if (publish) await emitPublished(await byId(id), v, traceparent);
+ return await byId(id);
+ });
} catch (e) { return publishConflict(e); }
} catch (e) { return publishConflict(e); }
}
}
/** An edit: version N+1 from the latest terms plus the changes. Published plans publish it. */
/** An edit: version N+1 from the latest terms plus the changes. Published plans publish it. */
- function update(planId, { name, description, benefits, perks: perkRefs, changeNote, actor = null, traceparent }) {
- const plan = byId(planId);
+ async function update(planId, { name, description, benefits, perks: perkRefs, changeNote, actor = null, traceparent }) {
+ const plan = await byId(planId);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be edited');
if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be edited');
- const last = latestVersion(planId);
+ const last = await latestVersion(planId);
const next = {
const next = {
name: name !== undefined ? text(name, 'name', 80, { required: true }) : last.name,
name: name !== undefined ? text(name, 'name', 80, { required: true }) : last.name,
description: description !== undefined ? text(description, 'description', 2000) : last.description,
description: description !== undefined ? text(description, 'description', 2000) : last.description,
benefits: benefits !== undefined ? cleanBenefits(benefits) : json(last.benefits, []),
benefits: benefits !== undefined ? cleanBenefits(benefits) : json(last.benefits, []),
};
};
- const perkRows = perkRefs !== undefined ? perks.resolveForPlan(plan.creator_id, perkRefs) : versionPerks(last.id).map((pp) => perks.byId(pp.perk_id)).filter((p) => p && p.status === 'active');
+ const perkRows = perkRefs !== undefined ? await perks.resolveForPlan(plan.creator_id, perkRefs) : (await Promise.all((await versionPerks(last.id)).map(async (pp) => await perks.byId(pp.perk_id)))).filter((p) => p && p.status === 'active');
const same = next.name === last.name && next.description === last.description && JSON.stringify(next.benefits) === last.benefits
const same = next.name === last.name && next.description === last.description && JSON.stringify(next.benefits) === last.benefits
- && JSON.stringify(perkRows.map((p) => p.id)) === JSON.stringify(versionPerks(last.id).map((pp) => pp.perk_id));
+ && JSON.stringify(perkRows.map((p) => p.id)) === JSON.stringify((await versionPerks(last.id)).map((pp) => pp.perk_id));
if (same) return { plan, version: last, unchanged: true };
if (same) return { plan, version: last, unchanged: true };
const publish = plan.status === 'published';
const publish = plan.status === 'published';
- return db.transaction(() => {
- const v = insertVersion(plan, { ...next, perkRows, changeNote: text(changeNote, 'change_note', 300), actor, publish });
- if (publish) emitPublished(byId(planId), v, traceparent);
- return { plan: byId(planId), version: v, unchanged: false };
- })();
+ return await db.tx(async () => {
+ const v = await insertVersion(plan, { ...next, perkRows, changeNote: text(changeNote, 'change_note', 300), actor, publish });
+ if (publish) await emitPublished(await byId(planId), v, traceparent);
+ return { plan: await byId(planId), version: v, unchanged: false };
+ });
}
}
- function publish(planId, { traceparent } = {}) {
- const plan = byId(planId);
+ async function publish(planId, { traceparent } = {}) {
+ const plan = await byId(planId);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
if (plan.status === 'published') return { plan, replay: true };
if (plan.status === 'published') return { plan, replay: true };
if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be published again; create a new plan');
if (plan.status === 'archived') fail(409, 'vip.plan_archived', 'an archived plan cannot be published again; create a new plan');
try {
try {
- return db.transaction(() => {
- const v = latestVersion(planId);
+ return await db.tx(async () => {
+ const v = await latestVersion(planId);
const at = iso(now());
const at = iso(now());
- db.prepare('UPDATE vip_plan_versions SET published_at = ? WHERE id = ? AND published_at IS NULL').run(at, v.id);
- db.prepare("UPDATE vip_plans SET status = 'published', current_version_id = ?, updated_at = ? WHERE id = ?").run(v.id, at, planId);
- emitPublished(byId(planId), version(v.id), traceparent);
- return { plan: byId(planId), replay: false };
- })();
+ await db.prepare('UPDATE vip_plan_versions SET published_at = ? WHERE id = ? AND published_at IS NULL').run(at, v.id);
+ await db.prepare("UPDATE vip_plans SET status = 'published', current_version_id = ?, updated_at = ? WHERE id = ?").run(v.id, at, planId);
+ await emitPublished(await byId(planId), await version(v.id), traceparent);
+ return { plan: await byId(planId), replay: false };
+ });
} catch (e) { return publishConflict(e); }
} catch (e) { return publishConflict(e); }
}
}
/** No new members; existing members keep their terms until Billing ends their membership. */
/** No new members; existing members keep their terms until Billing ends their membership. */
- function archive(planId) {
- const plan = byId(planId);
+ async function archive(planId) {
+ const plan = await byId(planId);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
if (!plan) fail(404, 'vip.plan_not_found', `no plan ${planId}`);
if (plan.status === 'archived') return plan;
if (plan.status === 'archived') return plan;
const at = iso(now());
const at = iso(now());
- db.prepare("UPDATE vip_plans SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(at, at, planId);
- return byId(planId);
+ await db.prepare("UPDATE vip_plans SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?").run(at, at, planId);
+ return await byId(planId);
}
}
- function list({ creatorId, includeDrafts = false, includeArchived = false }) {
+ async function list({ creatorId, includeDrafts = false, includeArchived = false }) {
const st = ['published'];
const st = ['published'];
if (includeDrafts) st.push('draft');
if (includeDrafts) st.push('draft');
if (includeArchived) st.push('archived');
if (includeArchived) st.push('archived');
- return db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);
+ return await db.prepare(`SELECT * FROM vip_plans WHERE creator_id = ? AND status IN (${st.map(() => '?').join(',')}) ORDER BY sort, created_at`).all(creatorId, ...st);
}
}
/** What a new member of this creator buys through Billing's product `kind`. */
/** What a new member of this creator buys through Billing's product `kind`. */
- const purchasable = (creatorId, kind = 'channel_subscription') => db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND status = 'published'").get(creatorId, kind) || null;
+ const purchasable = async (creatorId, kind = 'channel_subscription') => await db.prepare("SELECT * FROM vip_plans WHERE creator_id = ? AND billing_kind = ? AND status = 'published'").get(creatorId, kind) || null;
- function presentVersion(v) {
+ async function presentVersion(v) {
if (!v) return null;
if (!v) return null;
return {
return {
id: v.id, version: v.version, name: v.name, description: v.description || null, benefits: json(v.benefits, []),
id: v.id, version: v.version, name: v.name, description: v.description || null, benefits: json(v.benefits, []),
- perks: versionPerks(v.id).map((p) => ({ id: p.perk_id, key: p.perk_key, name: p.perk_name })),
+ perks: (await versionPerks(v.id)).map((p) => ({ id: p.perk_id, key: p.perk_key, name: p.perk_name })),
terms: json(v.terms, {}), change_note: v.change_note || null, created_at: v.created_at, published_at: v.published_at || null,
terms: json(v.terms, {}), change_note: v.change_note || null, created_at: v.created_at, published_at: v.published_at || null,
};
};
}
}
- function present(plan, { withVersions = false } = {}) {
+ async function present(plan, { withVersions = false } = {}) {
if (!plan) return null;
if (!plan) return null;
- const current = plan.current_version_id ? version(plan.current_version_id) : null;
- const latest = latestVersion(plan.id);
+ const current = plan.current_version_id ? await version(plan.current_version_id) : null;
+ const latest = await latestVersion(plan.id);
return {
return {
id: plan.id, creator_id: plan.creator_id, slug: plan.slug, status: plan.status, billing_kind: plan.billing_kind,
id: plan.id, creator_id: plan.creator_id, slug: plan.slug, status: plan.status, billing_kind: plan.billing_kind,
purchasable: plan.status === 'published' && !!plan.billing_kind,
purchasable: plan.status === 'published' && !!plan.billing_kind,
- current_version: presentVersion(current),
- draft_version: latest && (!current || latest.version > current.version) ? presentVersion(latest) : null,
- versions: withVersions ? versions(plan.id).map(presentVersion) : undefined,
+ current_version: await presentVersion(current),
+ draft_version: latest && (!current || latest.version > current.version) ? await presentVersion(latest) : null,
+ versions: withVersions ? (await Promise.all((await versions(plan.id)).map(presentVersion))) : undefined,
created_at: plan.created_at, updated_at: plan.updated_at, archived_at: plan.archived_at || null,
created_at: plan.created_at, updated_at: plan.updated_at, archived_at: plan.archived_at || null,
};
};
}
}
@@ -25,53 +25,53 @@
@@ -25,53 +25,53 @@
const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../util');
const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../util');
function createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {
function createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {
- const byId = (id) => db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;
- const forResource = (r, creatorId) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND creator_id = ? AND status = 'active'`)
+ const byId = async (id) => await db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;
+ const forResource = async (r, creatorId) => await db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND creator_id = ? AND status = 'active'`)
.get(r.service, r.type, r.id, creatorId) || null;
.get(r.service, r.type, r.id, creatorId) || null;
/** The creator a product names as a resource's owner: SubjectRef, usr_ id, or 'network'. */
/** The creator a product names as a resource's owner: SubjectRef, usr_ id, or 'network'. */
- function ownerCreator(owner) {
- if (owner === 'network') return creators.network();
+ async function ownerCreator(owner) {
+ if (owner === 'network') return await creators.network();
const s = owner && typeof owner === 'object' ? owner.id : owner;
const s = owner && typeof owner === 'object' ? owner.id : owner;
- return isUserSubject(String(s || '')) ? creators.bySubject(String(s)) : null;
+ return isUserSubject(String(s || '')) ? await creators.bySubject(String(s)) : null;
}
}
/** Create or replace the active rule for a resource. */
/** Create or replace the active rule for a resource. */
- function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {
- const creator = creators.byId(creatorId);
+ async function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {
+ const creator = await creators.byId(creatorId);
if (!creator || creator.kind !== 'creator') fail(404, 'vip.creator_not_found', 'gated resources belong to a creator');
if (!creator || creator.kind !== 'creator') fail(404, 'vip.creator_not_found', 'gated resources belong to a creator');
const r = entityRef(resource);
const r = entityRef(resource);
if (!['member', 'plan', 'perk'].includes(requirement)) fail(422, 'vip.invalid_input', 'requirement must be member, plan or perk');
if (!['member', 'plan', 'perk'].includes(requirement)) fail(422, 'vip.invalid_input', 'requirement must be member, plan or perk');
if (requirement === 'plan') {
if (requirement === 'plan') {
- const p = plans.byId(String(planId || ''));
+ const p = await plans.byId(String(planId || ''));
if (!p || p.creator_id !== creatorId) fail(422, 'vip.plan_not_found', 'plan_id must be one of this creator\'s plans');
if (!p || p.creator_id !== creatorId) fail(422, 'vip.plan_not_found', 'plan_id must be one of this creator\'s plans');
} else planId = null;
} else planId = null;
if (requirement === 'perk') {
if (requirement === 'perk') {
perkKey = text(perkKey, 'perk_key', 48, { required: true });
perkKey = text(perkKey, 'perk_key', 48, { required: true });
- if (!perks.byKey(creatorId, perkKey) && !perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);
+ if (!await perks.byKey(creatorId, perkKey) && !await perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);
} else perkKey = null;
} else perkKey = null;
- const existing = forResource(r, creatorId);
+ const existing = await forResource(r, creatorId);
const at = iso(now());
const at = iso(now());
const id = prefixedId('vgr', now());
const id = prefixedId('vgr', now());
- db.transaction(() => {
- if (existing) db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(at, existing.id);
- db.prepare(`INSERT INTO vip_gated_resource_rules (id, creator_id, resource_service, resource_type, resource_id, requirement, plan_id, perk_key, sensitive, status, created_by, created_at, updated_at)
+ await db.tx(async () => {
+ if (existing) await db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(at, existing.id);
+ await db.prepare(`INSERT INTO vip_gated_resource_rules (id, creator_id, resource_service, resource_type, resource_id, requirement, plan_id, perk_key, sensitive, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, r.service, r.type, r.id, requirement, planId, perkKey, sensitive ? 1 : 0, actor, at, at);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(id, creatorId, r.service, r.type, r.id, requirement, planId, perkKey, sensitive ? 1 : 0, actor, at, at);
- })();
- return byId(id);
+ });
+ return await byId(id);
}
}
- function disable(id) {
- const rule = byId(id);
+ async function disable(id) {
+ const rule = await byId(id);
if (!rule) fail(404, 'vip.rule_not_found', `no rule ${id}`);
if (!rule) fail(404, 'vip.rule_not_found', `no rule ${id}`);
- db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(iso(now()), id);
- return byId(id);
+ await db.prepare("UPDATE vip_gated_resource_rules SET status = 'disabled', updated_at = ? WHERE id = ?").run(iso(now()), id);
+ return await byId(id);
}
}
- const list = (creatorId) => db.prepare("SELECT * FROM vip_gated_resource_rules WHERE creator_id = ? AND status = 'active' ORDER BY created_at DESC").all(creatorId);
+ const list = async (creatorId) => await db.prepare("SELECT * FROM vip_gated_resource_rules WHERE creator_id = ? AND status = 'active' ORDER BY created_at DESC").all(creatorId);
- function present(rule) {
+ async function present(rule) {
if (!rule) return null;
if (!rule) return null;
- const c = creators.byId(rule.creator_id);
+ const c = await creators.byId(rule.creator_id);
return {
return {
id: rule.id, creator: c && c.subject ? { type: 'user', id: c.subject } : null, creator_id: rule.creator_id,
id: rule.id, creator: c && c.subject ? { type: 'user', id: c.subject } : null, creator_id: rule.creator_id,
resource: { service: rule.resource_service, type: rule.resource_type, id: rule.resource_id },
resource: { service: rule.resource_service, type: rule.resource_type, id: rule.resource_id },
@@ -94,10 +94,10 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
@@ -94,10 +94,10 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
}
}
/** The owner's active perks (own and network) that carry a product binding: the keys a member must hold. */
/** The owner's active perks (own and network) that carry a product binding: the keys a member must hold. */
- function boundPerkKeys(creatorId, { product, binding }) {
- return perks.list({ creatorId, includeNetwork: true })
- .filter((p) => perks.bindingsOf(p.id).some((b) => b.product === product && b.binding === binding))
- .map((p) => p.key);
+ async function boundPerkKeys(creatorId, { product, binding }) {
+ const list = await perks.list({ creatorId, includeNetwork: true });
+ const bound = await Promise.all(list.map(async (p) => (await perks.bindingsOf(p.id)).some((b) => b.product === product && b.binding === binding)));
+ return list.filter((_, i) => bound[i]).map((p) => p.key);
}
}
/** evaluate() when the owner has no rule and the product passed its default gate. */
/** evaluate() when the owner has no rule and the product passed its default gate. */
@@ -114,9 +114,9 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
@@ -114,9 +114,9 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
if (ent.status === 'unknown') return deny('entitlement_unknown', { ...base, entitlement: ent });
if (ent.status === 'unknown') return deny('entitlement_unknown', { ...base, entitlement: ent });
if (!ent.active) return deny('not_a_member', { ...base, entitlement: ent });
if (!ent.active) return deny('not_a_member', { ...base, entitlement: ent });
if (fb.binding) {
if (fb.binding) {
- const needed = boundPerkKeys(oc.id, fb.binding);
+ const needed = await boundPerkKeys(oc.id, fb.binding);
if (needed.length) {
if (needed.length) {
- const held = memberships.perkKeys(memberships.get(subject, oc.id));
+ const held = await memberships.perkKeys(await memberships.get(subject, oc.id));
if (!needed.some((k) => held.includes(k))) return deny('perk_missing', { ...base, entitlement: ent });
if (!needed.some((k) => held.includes(k))) return deny('perk_missing', { ...base, entitlement: ent });
}
}
}
}
@@ -134,35 +134,35 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
@@ -134,35 +134,35 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
let fb;
let fb;
try { fb = cleanFallback(fallback); } catch { return deny('invalid_fallback'); }
try { fb = cleanFallback(fallback); } catch { return deny('invalid_fallback'); }
if (!owner) return deny('owner_required');
if (!owner) return deny('owner_required');
- let oc = ownerCreator(owner);
+ let oc = await ownerCreator(owner);
// A creator VIP has never seen can still hold Billing subscriptions: the default gate asks for them.
// A creator VIP has never seen can still hold Billing subscriptions: the default gate asks for them.
if (!oc && fb && !ruleId && owner !== 'network') {
if (!oc && fb && !ruleId && owner !== 'network') {
const s = owner && typeof owner === 'object' ? owner.id : owner;
const s = owner && typeof owner === 'object' ? owner.id : owner;
if (isUserSubject(String(s || ''))) oc = { id: null, subject: String(s), status: 'active', kind: 'creator' };
if (isUserSubject(String(s || ''))) oc = { id: null, subject: String(s), status: 'active', kind: 'creator' };
}
}
if (!oc) return deny('no_rule');
if (!oc) return deny('no_rule');
- const rule = ruleId ? byId(String(ruleId)) : (oc.id ? forResource(r, oc.id) : null);
- if (!rule && fb && !ruleId) return evaluateFallback({ subject, oc, fb, mode, traceparent });
+ const rule = ruleId ? await byId(String(ruleId)) : (oc.id ? await forResource(r, oc.id) : null);
+ if (!rule && fb && !ruleId) return await evaluateFallback({ subject, oc, fb, mode, traceparent });
if (!rule) return deny('no_rule');
if (!rule) return deny('no_rule');
- if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });
- if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: present(rule) });
- if (rule.status !== 'active') return deny('rule_disabled', { rule: present(rule) });
- const creator = creators.byId(rule.creator_id);
- if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: present(rule) });
- if (!subject) return deny('not_signed_in', { rule: present(rule) });
- if (!isUserSubject(subject)) return deny('not_a_member', { rule: present(rule) });
+ if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: await present(rule) });
+ if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: await present(rule) });
+ if (rule.status !== 'active') return deny('rule_disabled', { rule: await present(rule) });
+ const creator = await creators.byId(rule.creator_id);
+ if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: await present(rule) });
+ if (!subject) return deny('not_signed_in', { rule: await present(rule) });
+ if (!isUserSubject(subject)) return deny('not_a_member', { rule: await present(rule) });
// The creator always sees their own gated resources.
// The creator always sees their own gated resources.
- if (subject === creator.subject) return { allow: true, reason: 'owner', rule: present(rule), entitlement: null };
+ if (subject === creator.subject) return { allow: true, reason: 'owner', rule: await present(rule), entitlement: null };
let ent;
let ent;
try {
try {
ent = await entitlements.check(subject, creator.subject, { mode: rule.sensitive ? 'authoritative' : (mode === 'authoritative' ? 'authoritative' : 'auto'), traceparent });
ent = await entitlements.check(subject, creator.subject, { mode: rule.sensitive ? 'authoritative' : (mode === 'authoritative' ? 'authoritative' : 'auto'), traceparent });
- } catch { return deny('entitlement_unknown', { rule: present(rule) }); }
- if (ent.status === 'unknown') return deny('entitlement_unknown', { rule: present(rule), entitlement: ent });
- if (!ent.active) return deny('not_a_member', { rule: present(rule), entitlement: ent });
- const m = memberships.get(subject, creator.id);
- if (rule.requirement === 'plan' && (!m || m.plan_id !== rule.plan_id)) return deny('plan_required', { rule: present(rule), entitlement: ent });
- if (rule.requirement === 'perk' && !memberships.perkKeys(m).includes(rule.perk_key)) return deny('perk_missing', { rule: present(rule), entitlement: ent });
- return { allow: true, reason: 'member', rule: present(rule), entitlement: ent };
+ } catch { return deny('entitlement_unknown', { rule: await present(rule) }); }
+ if (ent.status === 'unknown') return deny('entitlement_unknown', { rule: await present(rule), entitlement: ent });
+ if (!ent.active) return deny('not_a_member', { rule: await present(rule), entitlement: ent });
+ const m = await memberships.get(subject, creator.id);
+ if (rule.requirement === 'plan' && (!m || m.plan_id !== rule.plan_id)) return deny('plan_required', { rule: await present(rule), entitlement: ent });
+ if (rule.requirement === 'perk' && !(await memberships.perkKeys(m)).includes(rule.perk_key)) return deny('perk_missing', { rule: await present(rule), entitlement: ent });
+ return { allow: true, reason: 'member', rule: await present(rule), entitlement: ent };
}
}
return { byId, forResource, ownerCreator, set, disable, list, present, evaluate };
return { byId, forResource, ownerCreator, set, disable, list, present, evaluate };
@@ -18,18 +18,17 @@
@@ -18,18 +18,17 @@
*/
*/
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
-const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const { isUserSubject } = require('../util');
const { isUserSubject } = require('../util');
const CONSUMER = 'vip-billing';
const CONSUMER = 'vip-billing';
function consumerRouter({ domain, config, log = console }) {
function consumerRouter({ domain, config, log = console }) {
const router = express.Router();
const router = express.Router();
- const inbox = createInbox(domain.db, { now: domain.now });
- inbox.ensureSchema();
+ const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sql
const { entitlements } = domain;
const { entitlements } = domain;
- function handle(event) {
+ async function handle(event) {
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
const asOf = Date.parse(event.timestamp);
const asOf = Date.parse(event.timestamp);
if (!Number.isFinite(asOf)) return 'ignored:bad_timestamp';
if (!Number.isFinite(asOf)) return 'ignored:bad_timestamp';
@@ -39,21 +38,21 @@ function consumerRouter({ domain, config, log = console }) {
@@ -39,21 +38,21 @@ function consumerRouter({ domain, config, log = console }) {
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
if (p.kind && p.kind !== entitlements.KIND) return 'ignored:kind';
if (p.kind && p.kind !== entitlements.KIND) return 'ignored:kind';
const sub = p.subscription || null;
const sub = p.subscription || null;
- return entitlements.apply({
+ return (await entitlements.apply({
member, creator, active: !!p.active, expiresAt: p.expires_at || null,
member, creator, active: !!p.active, expiresAt: p.expires_at || null,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
cancelAtPeriodEnd: sub ? !!sub.cancel_at_period_end : undefined, subscriptionId: sub ? sub.id : undefined,
subscriptionStatus: sub ? sub.status : undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,
subscriptionStatus: sub ? sub.status : undefined, reason: p.reason || null, source: 'event', eventId: event.event_id, asOf,
- }).outcome;
+ })).outcome;
}
}
if (event.event_type === 'billing.subscription.canceled') {
if (event.event_type === 'billing.subscription.canceled') {
const s = p.subscription || {};
const s = p.subscription || {};
const member = s.subscriber && s.subscriber.id;
const member = s.subscriber && s.subscriber.id;
const creator = s.streamer && s.streamer.id;
const creator = s.streamer && s.streamer.id;
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
- return entitlements.applyCanceled({
+ return (await entitlements.applyCanceled({
member, creator, subscriptionId: s.id || null, subscriptionStatus: s.status || null,
member, creator, subscriptionId: s.id || null, subscriptionStatus: s.status || null,
currentPeriodEnd: s.current_period_end || null, eventId: event.event_id, asOf,
currentPeriodEnd: s.current_period_end || null, eventId: event.event_id, asOf,
- }).outcome;
+ })).outcome;
}
}
if (event.event_type === 'billing.transaction.reversed') {
if (event.event_type === 'billing.transaction.reversed') {
if (!(Number(p.entitlements_revoked) > 0)) return 'ignored:no_entitlement';
if (!(Number(p.entitlements_revoked) > 0)) return 'ignored:no_entitlement';
@@ -61,18 +60,18 @@ function consumerRouter({ domain, config, log = console }) {
@@ -61,18 +60,18 @@ function consumerRouter({ domain, config, log = console }) {
const member = p.to_subject;
const member = p.to_subject;
const creator = p.from_subject;
const creator = p.from_subject;
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
if (!isUserSubject(member) || !isUserSubject(creator)) return 'ignored:bad_subjects';
- return entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';
+ return await entitlements.doubt(member, creator) ? 'doubt' : 'no_projection';
}
}
return 'ignored:type';
return 'ignored:type';
}
}
/** Apply one envelope (also used by tests). Returns { duplicate, outcome }. */
/** Apply one envelope (also used by tests). Returns { duplicate, outcome }. */
- function apply(event) {
- const r = inbox.once(CONSUMER, event.event_id, () => (event.source !== 'billing' ? 'ignored:source' : handle(event)));
+ async function apply(event) {
+ const r = await inbox.once(CONSUMER, event.event_id, async () => (event.source !== 'billing' ? 'ignored:source' : await handle(event)));
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
}
}
- router.post('/events', express.raw({ type: () => true, limit: '256kb' }), (req, res) => {
+ router.post('/events', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
const secrets = config.events.webhookSecrets;
const secrets = config.events.webhookSecrets;
if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });
if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
@@ -86,7 +85,7 @@ function consumerRouter({ domain, config, log = console }) {
@@ -86,7 +85,7 @@ function consumerRouter({ domain, config, log = console }) {
}
}
let out;
let out;
try {
try {
- out = apply(event);
+ out = await apply(event);
} catch (e) {
} catch (e) {
// Not acknowledged: Events retries it, and the inbox claim rolled back with the change.
// Not acknowledged: Events retries it, and the inbox claim rolled back with the change.
log.error(`[VIP] event ${event.event_id} (${event.event_type}) failed:`, e.message);
log.error(`[VIP] event ${event.event_id} (${event.event_type}) failed:`, e.message);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
const { createClient } = require('openvibe-sdk/core');
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
const ACTOR = { type: 'service', id: 'vip' };
const ACTOR = { type: 'service', id: 'vip' };
@@ -35,7 +35,9 @@ function createVipOutbox({ db, config, fetchImpl, now = () => Date.now(), log =
@@ -35,7 +35,9 @@ function createVipOutbox({ db, config, fetchImpl, now = () => Date.now(), log =
}
}
const events = createEventsClient(createClient(clientOpts), { source: 'vip' });
const events = createEventsClient(createClient(clientOpts), { source: 'vip' });
let lastError = null;
let lastError = null;
- const outbox = createOutbox(db, {
+ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient
+ // transaction); several processes relay one table safely (leases).
+ const outbox = createPgOutbox(db, {
events,
events,
intervalMs: config.events.intervalMs,
intervalMs: config.events.intervalMs,
now,
now,
@@ -45,14 +47,13 @@ function createVipOutbox({ db, config, fetchImpl, now = () => Date.now(), log =
@@ -45,14 +47,13 @@ function createVipOutbox({ db, config, fetchImpl, now = () => Date.now(), log =
lastError = msg;
lastError = msg;
},
},
});
});
- outbox.ensureSchema();
/** Inside the caller's transaction. */
/** Inside the caller's transaction. */
function emit(eventType, subject, payload, { visibility = 'internal', priority = 'important', traceparent } = {}) {
function emit(eventType, subject, payload, { visibility = 'internal', priority = 'important', traceparent } = {}) {
const env = events.prepare({ event_type: eventType, actor: ACTOR, subject, payload, visibility, priority }, { now: now() });
const env = events.prepare({ event_type: eventType, actor: ACTOR, subject, payload, visibility, priority }, { now: now() });
const v = validate('events.event-envelope@1', env);
const v = validate('events.event-envelope@1', env);
if (!v.valid) throw new Error(`outbox: invalid envelope for ${eventType}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
if (!v.valid) throw new Error(`outbox: invalid envelope for ${eventType}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
- return outbox.enqueue(env, { traceparent });
+ return outbox.enqueue(db, env, { traceparent });
}
}
return {
return {
@@ -30,8 +30,8 @@
@@ -30,8 +30,8 @@
*/
*/
const { iso } = require('../util');
const { iso } = require('../util');
-const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
-const columns = (db, t) => (tableExists(db, t) ? db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name) : []);
+const tableExists = async (db, name) => !!await db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
+const columns = async (db, t) => (await tableExists(db, t) ? (await db.prepare(`PRAGMA table_info(${t})`).all()).map((c) => c.name) : []);
const CREATOR_ROLES = ['streamer', 'global_mod', 'admin'];
const CREATOR_ROLES = ['streamer', 'global_mod', 'admin'];
const PLAN_SLUG = 'channel-subscription';
const PLAN_SLUG = 'channel-subscription';
const PERK_KEY = 'subscriber-badge';
const PERK_KEY = 'subscriber-badge';
@@ -53,16 +53,16 @@ function billingSnapshotSource(bdb, { now = () => Date.now() } = {}) {
@@ -53,16 +53,16 @@ function billingSnapshotSource(bdb, { now = () => Date.now() } = {}) {
const subs = bdb.prepare('SELECT * FROM subscriptions WHERE streamer = ? ORDER BY created_at');
const subs = bdb.prepare('SELECT * FROM subscriptions WHERE streamer = ? ORDER BY created_at');
return {
return {
name: 'billing snapshot',
name: 'billing snapshot',
- listSubscriptions: async (creator) => subs.all(creator).map((s) => ({
+ listSubscriptions: async (creator) => (await subs.all(creator)).map((s) => ({
id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, status: s.status,
id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, status: s.status,
auto_renew: !!s.auto_renew, cancel_at_period_end: !!s.cancel_at_period_end, current_period_end: s.current_period_end, legacy_live_id: s.legacy_live_id ?? null,
auto_renew: !!s.auto_renew, cancel_at_period_end: !!s.cancel_at_period_end, current_period_end: s.current_period_end, legacy_live_id: s.legacy_live_id ?? null,
})),
})),
entitlement: async (member, creator) => {
entitlement: async (member, creator) => {
const at = iso(now());
const at = iso(now());
- const rows = bdb.prepare(`SELECT starts_at, ends_at FROM entitlements WHERE subject = ? AND kind = 'channel_subscription' AND scope = ?
+ const rows = await bdb.prepare(`SELECT starts_at, ends_at FROM entitlements WHERE subject = ? AND kind = 'channel_subscription' AND scope = ?
AND revoked_at IS NULL AND ends_at > ? ORDER BY starts_at`).all(member, creator, at);
AND revoked_at IS NULL AND ends_at > ? ORDER BY starts_at`).all(member, creator, at);
const active = rows.some((r) => r.starts_at <= at);
const active = rows.some((r) => r.starts_at <= at);
- const sub = bdb.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(member, creator);
+ const sub = await bdb.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(member, creator);
return {
return {
active, expires_at: active ? rows.reduce((m, r) => (r.ends_at > m ? r.ends_at : m), '') : null,
active, expires_at: active ? rows.reduce((m, r) => (r.ends_at > m ? r.ends_at : m), '') : null,
subscription: sub ? { id: sub.id, status: sub.status, auto_renew: !!sub.auto_renew, cancel_at_period_end: !!sub.cancel_at_period_end } : null,
subscription: sub ? { id: sub.id, status: sub.status, auto_renew: !!sub.auto_renew, cancel_at_period_end: !!sub.cancel_at_period_end } : null,
@@ -87,16 +87,16 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
@@ -87,16 +87,16 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
};
};
// ── Read the snapshot ────────────────────────────────────
// ── Read the snapshot ────────────────────────────────────
- const hasUsers = tableExists(live, 'users');
- const userCols = columns(live, 'users');
- const users = hasUsers ? live.prepare(`SELECT id, username, display_name${userCols.includes('role') ? ', role' : ", 'user' AS role"} FROM users`).all() : [];
+ const hasUsers = await tableExists(live, 'users');
+ const userCols = await columns(live, 'users');
+ const users = hasUsers ? await live.prepare(`SELECT id, username, display_name${userCols.includes('role') ? ', role' : ", 'user' AS role"} FROM users`).all() : [];
const byLiveId = new Map(users.map((u) => [String(u.id), u]));
const byLiveId = new Map(users.map((u) => [String(u.id), u]));
- const subCols = columns(live, 'subscriptions');
- const liveSubs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
- if (tableExists(live, 'site_settings')) {
- const get = (k) => { const r = live.prepare('SELECT value FROM site_settings WHERE key = ?').get(k); return r ? r.value : null; };
+ const subCols = await columns(live, 'subscriptions');
+ const liveSubs = subCols.length ? await live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
+ if (await tableExists(live, 'site_settings')) {
+ const get = async (k) => { const r = await live.prepare('SELECT value FROM site_settings WHERE key = ?').get(k); return r ? r.value : null; };
report.live_offering = {
report.live_offering = {
- sub_price_usd: get('sub_price_usd'), sub_streamer_share_pct: get('sub_streamer_share_pct'), sub_site_route_fee_pct: get('sub_site_route_fee_pct'),
+ sub_price_usd: await get('sub_price_usd'), sub_streamer_share_pct: await get('sub_streamer_share_pct'), sub_site_route_fee_pct: await get('sub_site_route_fee_pct'),
note: 'recorded for reference only: OpenVibe.Billing prices and charges subscriptions; VIP plan terms carry no price',
note: 'recorded for reference only: OpenVibe.Billing prices and charges subscriptions; VIP plan terms carry no price',
};
};
}
}
@@ -136,13 +136,13 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
@@ -136,13 +136,13 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
VALUES (@source, @table, @id, @target_type, @target_id, @status, @reason, @run, @at, @at)
VALUES (@source, @table, @id, @target_type, @target_id, @status, @reason, @run, @at, @at)
ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,
ON CONFLICT (source, source_table, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id, status = excluded.status,
reason = excluded.reason, run_id = excluded.run_id, updated_at = excluded.updated_at`);
reason = excluded.reason, run_id = excluded.run_id, updated_at = excluded.updated_at`);
- const record = (source, table, id, fields) => upsertMap.run({ source, table, id: String(id), target_type: null, target_id: null, reason: null, run: runId, at, ...fields });
+ const record = async (source, table, id, fields) => await upsertMap.run({ source, table, id: String(id), target_type: null, target_id: null, reason: null, run: runId, at, ...fields });
- const run = () => {
+ const run = async () => {
// ── 1. The network perk ──────────────────────────────
// ── 1. The network perk ──────────────────────────────
- let perk = perks.byKey('network', PERK_KEY);
+ let perk = await perks.byKey('network', PERK_KEY);
if (!perk) {
if (!perk) {
- perk = perks.create({
+ perk = await perks.create({
creatorId: 'network', key: PERK_KEY, name: 'Subscriber badge', kind: 'badge', actor: ACTOR,
creatorId: 'network', key: PERK_KEY, name: 'Subscriber badge', kind: 'badge', actor: ACTOR,
description: 'A star badge next to your name in the creator\'s chat, as channel subscribers had on OpenVibe.Live.',
description: 'A star badge next to your name in the creator\'s chat, as channel subscribers had on OpenVibe.Live.',
bindings: [
bindings: [
@@ -152,48 +152,48 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
@@ -152,48 +152,48 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
{ product: 'chat', binding: 'badge', config: { badge: 'subscriber' } },
{ product: 'chat', binding: 'badge', config: { badge: 'subscriber' } },
],
],
});
});
- record('live', 'offering', 'subscriber_badge', { target_type: 'perk', target_id: perk.id, status: 'imported' });
+ await record('live', 'offering', 'subscriber_badge', { target_type: 'perk', target_id: perk.id, status: 'imported' });
}
}
// ── 2 + 3. Creators and their plan ───────────────────
// ── 2 + 3. Creators and their plan ───────────────────
for (const id of [...creatorIds].sort((a, b) => Number(a) - Number(b))) {
for (const id of [...creatorIds].sort((a, b) => Number(a) - Number(b))) {
- const prev = mapRow.get('live', 'users', id);
+ const prev = await mapRow.get('live', 'users', id);
const subject = subjectOf(id);
const subject = subjectOf(id);
const u = byLiveId.get(id);
const u = byLiveId.get(id);
if (!subject) {
if (!subject) {
const reason = `Live user ${id}${u ? ` (${u.username})` : ''} has no Network subject yet`;
const reason = `Live user ${id}${u ? ` (${u.username})` : ''} has no Network subject yet`;
- record('live', 'users', id, { status: 'held', reason });
+ await record('live', 'users', id, { status: 'held', reason });
report.counts.creators.held++; report.holds.push({ live_user: Number(id), reason });
report.counts.creators.held++; report.holds.push({ live_user: Number(id), reason });
continue;
continue;
}
}
- const existed = creators.bySubject(subject);
- const c = creators.ensure({ subject, username: handleOf(id), displayName: u ? u.display_name : null, origin: existed ? existed.origin : 'import' });
+ const existed = await creators.bySubject(subject);
+ const c = await creators.ensure({ subject, username: handleOf(id), displayName: u ? u.display_name : null, origin: existed ? existed.origin : 'import' });
if (prev && prev.status !== 'held') report.counts.creators.unchanged++;
if (prev && prev.status !== 'held') report.counts.creators.unchanged++;
else { report.counts.creators[prev ? 'released' : 'imported']++; }
else { report.counts.creators[prev ? 'released' : 'imported']++; }
- record('live', 'users', id, { target_type: 'creator', target_id: c.id, status: 'imported', reason: prev && prev.status === 'held' ? 'released from hold' : null });
+ await record('live', 'users', id, { target_type: 'creator', target_id: c.id, status: 'imported', reason: prev && prev.status === 'held' ? 'released from hold' : null });
- const offer = mapRow.get('live', 'offering', `channel_subscription:${id}`);
- if (offer && offer.target_id && plans.byId(offer.target_id)) { report.counts.plans.unchanged++; continue; }
- const own = plans.purchasable(c.id);
+ const offer = await mapRow.get('live', 'offering', `channel_subscription:${id}`);
+ if (offer && offer.target_id && await plans.byId(offer.target_id)) { report.counts.plans.unchanged++; continue; }
+ const own = await plans.purchasable(c.id);
if (own) {
if (own) {
- record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: own.id, status: 'linked', reason: 'the creator already publishes a plan sold as the channel subscription' });
+ await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: own.id, status: 'linked', reason: 'the creator already publishes a plan sold as the channel subscription' });
report.counts.plans.linked++;
report.counts.plans.linked++;
continue;
continue;
}
}
- const existingSlug = db.prepare('SELECT * FROM vip_plans WHERE creator_id = ? AND slug = ?').get(c.id, PLAN_SLUG);
+ const existingSlug = await db.prepare('SELECT * FROM vip_plans WHERE creator_id = ? AND slug = ?').get(c.id, PLAN_SLUG);
if (existingSlug) {
if (existingSlug) {
- record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: existingSlug.id, status: 'linked', reason: `plan ${PLAN_SLUG} already exists (status ${existingSlug.status})` });
+ await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: existingSlug.id, status: 'linked', reason: `plan ${PLAN_SLUG} already exists (status ${existingSlug.status})` });
report.counts.plans.linked++;
report.counts.plans.linked++;
continue;
continue;
}
}
const name = c.display_name || c.username || 'this creator';
const name = c.display_name || c.username || 'this creator';
- const plan = plans.create({
+ const plan = await plans.create({
creatorId: c.id, slug: PLAN_SLUG, name: 'Channel subscription',
creatorId: c.id, slug: PLAN_SLUG, name: 'Channel subscription',
description: `A monthly subscription to ${name}'s channel, carried over from OpenVibe.Live.`,
description: `A monthly subscription to ${name}'s channel, carried over from OpenVibe.Live.`,
benefits: [`A subscriber badge next to your name in ${name}'s chat`],
benefits: [`A subscriber badge next to your name in ${name}'s chat`],
perks: [PERK_KEY], billingKind: 'channel_subscription', publish: true, changeNote: 'imported from OpenVibe.Live', actor: ACTOR,
perks: [PERK_KEY], billingKind: 'channel_subscription', publish: true, changeNote: 'imported from OpenVibe.Live', actor: ACTOR,
});
});
- record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: plan.id, status: 'imported' });
+ await record('live', 'offering', `channel_subscription:${id}`, { target_type: 'plan', target_id: plan.id, status: 'imported' });
report.counts.plans.created++;
report.counts.plans.created++;
}
}
@@ -202,17 +202,17 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
@@ -202,17 +202,17 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
if (!list) continue;
if (!list) continue;
for (const sub of list) {
for (const sub of list) {
const member = sub.subscriber && sub.subscriber.id;
const member = sub.subscriber && sub.subscriber.id;
- const prev = mapRow.get('billing', 'subscriptions', sub.id);
+ const prev = await mapRow.get('billing', 'subscriptions', sub.id);
if (sub.status !== 'active') {
if (sub.status !== 'active') {
if (!prev) {
if (!prev) {
const reason = `Billing subscription is ${sub.status}: no current membership to file terms for`;
const reason = `Billing subscription is ${sub.status}: no current membership to file terms for`;
- record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });
+ await record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });
report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });
report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });
} else report.counts.memberships.unchanged++;
} else report.counts.memberships.unchanged++;
continue;
continue;
}
}
const e = ents.get(`${member}:${creatorSubject}`) || { active: false, expires_at: null };
const e = ents.get(`${member}:${creatorSubject}`) || { active: false, expires_at: null };
- const out = entitlements.apply({
+ const out = await entitlements.apply({
member, creator: creatorSubject, active: !!e.active, expiresAt: e.expires_at || null,
member, creator: creatorSubject, active: !!e.active, expiresAt: e.expires_at || null,
cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id, subscriptionStatus: sub.status,
cancelAtPeriodEnd: !!sub.cancel_at_period_end, subscriptionId: sub.id, subscriptionStatus: sub.status,
reason: 'imported', source: 'import', origin: 'import', asOf: domain.now(),
reason: 'imported', source: 'import', origin: 'import', asOf: domain.now(),
@@ -220,27 +220,27 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
@@ -220,27 +220,27 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
const m = out.membership;
const m = out.membership;
if (!e.active || !m) {
if (!e.active || !m) {
const reason = 'Billing lists the subscription as active but its entitlement is not active now';
const reason = 'Billing lists the subscription as active but its entitlement is not active now';
- record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });
+ await record('billing', 'subscriptions', sub.id, { status: 'excluded', reason });
report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });
report.counts.memberships.excluded++; report.excluded.push({ billing_subscription: sub.id, reason });
continue;
continue;
}
}
if (prev && prev.status === 'imported' && prev.target_id === m.id) report.counts.memberships.unchanged++;
if (prev && prev.status === 'imported' && prev.target_id === m.id) report.counts.memberships.unchanged++;
else report.counts.memberships.imported++;
else report.counts.memberships.imported++;
- record('billing', 'subscriptions', sub.id, { target_type: 'membership', target_id: m.id, status: 'imported' });
+ await record('billing', 'subscriptions', sub.id, { target_type: 'membership', target_id: m.id, status: 'imported' });
}
}
}
}
// ── 5. Live's subscriptions rows ─────────────────────
// ── 5. Live's subscriptions rows ─────────────────────
const membershipFor = db.prepare('SELECT m.id FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?');
const membershipFor = db.prepare('SELECT m.id FROM vip_memberships m JOIN vip_creators c ON c.id = m.creator_id WHERE m.member_subject = ? AND c.subject = ?');
for (const s of liveSubs) {
for (const s of liveSubs) {
- const prev = mapRow.get('live', 'subscriptions', String(s.id));
+ const prev = await mapRow.get('live', 'subscriptions', String(s.id));
const member = subjectOf(s.subscriber_id);
const member = subjectOf(s.subscriber_id);
const creator = subjectOf(s.streamer_id);
const creator = subjectOf(s.streamer_id);
let fields;
let fields;
if (!member || !creator) {
if (!member || !creator) {
fields = { status: 'held', reason: `${!member ? `subscriber (Live user ${s.subscriber_id})` : `creator (Live user ${s.streamer_id})`} has no Network subject yet` };
fields = { status: 'held', reason: `${!member ? `subscriber (Live user ${s.subscriber_id})` : `creator (Live user ${s.streamer_id})`} has no Network subject yet` };
} else {
} else {
- const m = membershipFor.get(member, creator);
+ const m = await membershipFor.get(member, creator);
const bsub = (billingSubs.get(creator) || []).find((x) => x.subscriber && x.subscriber.id === member);
const bsub = (billingSubs.get(creator) || []).find((x) => x.subscriber && x.subscriber.id === member);
if (m) fields = { status: 'linked', target_type: 'membership', target_id: m.id };
if (m) fields = { status: 'linked', target_type: 'membership', target_id: m.id };
else if (bsub) fields = { status: 'linked', target_type: 'billing_subscription', target_id: bsub.id, reason: `Billing holds it (status ${bsub.status}); no current membership` };
else if (bsub) fields = { status: 'linked', target_type: 'billing_subscription', target_id: bsub.id, reason: `Billing holds it (status ${bsub.status}); no current membership` };
@@ -248,7 +248,7 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
@@ -248,7 +248,7 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
else fields = { status: 'held', reason: 'Billing has no subscription for this pair (run Billing\'s Live import first)' };
else fields = { status: 'held', reason: 'Billing has no subscription for this pair (run Billing\'s Live import first)' };
}
}
if (prev && prev.status === fields.status && prev.target_id === (fields.target_id || null)) { report.counts.live_subscriptions.unchanged++; continue; }
if (prev && prev.status === fields.status && prev.target_id === (fields.target_id || null)) { report.counts.live_subscriptions.unchanged++; continue; }
- record('live', 'subscriptions', s.id, fields);
+ await record('live', 'subscriptions', s.id, fields);
report.counts.live_subscriptions[fields.status === 'held' ? 'held' : 'linked']++;
report.counts.live_subscriptions[fields.status === 'held' ? 'held' : 'linked']++;
if (fields.status === 'held') report.holds.push({ live_subscription: s.id, reason: fields.reason });
if (fields.status === 'held') report.holds.push({ live_subscription: s.id, reason: fields.reason });
}
}
@@ -256,7 +256,7 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
@@ -256,7 +256,7 @@ async function importLive(domain, { live, billingSource, resolveLiveUsers, dryRu
};
};
try {
try {
- db.transaction(run)();
+ await db.tx(run);
} catch (e) {
} catch (e) {
if (!(e instanceof DryRun)) throw e;
if (!(e instanceof DryRun)) throw e;
}
}
@@ -16,20 +16,21 @@ if (config.isProduction && !config.formSecret) {
@@ -16,20 +16,21 @@ if (config.isProduction && !config.formSecret) {
console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');
console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');
process.exit(1);
process.exit(1);
}
}
-const app = createApp({ config });
+(async () => {
+const app = await createApp({ config });
const { domain, keys, outbox, metrics } = app.locals;
const { domain, keys, outbox, metrics } = app.locals;
keys.start();
keys.start();
const timers = [];
const timers = [];
if (config.jobs.enabled) {
if (config.jobs.enabled) {
const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
- every(config.jobs.refreshIntervalMs, () => domain.entitlements.refreshDue());
- every(6 * 3600 * 1000, () => outbox.outbox.prune());
+ every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());
+ every(6 * 3600 * 1000, async () => await outbox.outbox.prune());
outbox.start();
outbox.start();
}
}
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
- console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${domain.db.store})`);
console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);
console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
@@ -40,8 +41,9 @@ function shutdown(signal) {
@@ -40,8 +41,9 @@ function shutdown(signal) {
outbox.stop();
outbox.stop();
keys.stop();
keys.stop();
if (metrics && metrics.stop) metrics.stop();
if (metrics && metrics.stop) metrics.stop();
- server.close(() => { try { domain.db.close(); } catch { /* */ } process.exit(0); });
+ server.close(() => { Promise.resolve().then(() => domain.db.close()).catch(() => {}).finally(() => process.exit(0)); });
setTimeout(() => process.exit(0), 5000).unref();
setTimeout(() => process.exit(0), 5000).unref();
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGINT', () => shutdown('SIGINT'));
+})().catch((err) => { console.error('[VIP] failed to start:', err); process.exit(1); });
@@ -39,7 +39,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
@@ -39,7 +39,7 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
if (config.network.publicKey || timer) return;
if (config.network.publicKey || timer) return;
const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
retry();
retry();
- timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);
+ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);
timer.unref();
timer.unref();
}
}
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
@@ -94,7 +94,7 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -94,7 +94,7 @@ function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
body: JSON.stringify(body),
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000),
});
});
- if (res.status === 401 && !retried) { tokenClient().invalidate(); return post(body, true); }
+ if (res.status === 401 && !retried) { tokenClient().invalidate(); return await post(body, true); }
const data = await res.json().catch(() => null);
const data = await res.json().catch(() => null);
if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
return data.results;
return data.results;
@@ -16,12 +16,21 @@
@@ -16,12 +16,21 @@
*/
*/
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
-function createVipReadiness({ db, keys, config, outbox, now, release = null, fetchImpl = globalThis.fetch }) {
+function createVipReadiness({ db, keys, config, outbox, now, release = null, fetchImpl = globalThis.fetch, valkey = null }) {
return createReadiness({
return createReadiness({
service: 'vip',
service: 'vip',
release,
release,
checks: [
checks: [
- { name: 'db', required: true, check: () => db.prepare('SELECT COUNT(*) AS n FROM vip_creators').get().n >= 1 || 'database has no network creator row' },
+ {
+ name: 'db', required: true,
+ // A real round trip that names the store (postgresql / pglite), and the network creator row present.
+ check: async () => {
+ const r = await db.ready();
+ if (!r.ok) return r.error;
+ return (await db.prepare('SELECT COUNT(*) AS n FROM vip_creators').get()).n >= 1 ? { ok: true, detail: r.detail } : 'database has no network creator row';
+ },
+ },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified'),
check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified'),
@@ -35,10 +44,10 @@ function createVipReadiness({ db, keys, config, outbox, now, release = null, fet
@@ -35,10 +44,10 @@ function createVipReadiness({ db, keys, config, outbox, now, release = null, fet
},
},
},
},
],
],
- details: (body) => {
+ details: async (body) => {
if (body.checks.db.status !== 'ok') return {};
if (body.checks.db.status !== 'ok') return {};
const t = now();
const t = now();
- const p = db.prepare('SELECT COUNT(*) AS n, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t);
+ const p = await db.prepare('SELECT COUNT(*) AS n, SUM(CASE WHEN valid_until < ? THEN 1 ELSE 0 END) AS stale FROM vip_entitlement_projection').get(t);
return {
return {
events: outbox.status(),
events: outbox.status(),
billing_events_accepted: config.events.webhookSecrets.length > 0,
billing_events_accepted: config.events.webhookSecrets.length > 0,
@@ -85,7 +85,7 @@ function createEmbedRoutes({ domain, config }) {
@@ -85,7 +85,7 @@ function createEmbedRoutes({ domain, config }) {
/** The creator and its card, or null (unknown handle, suspended, no published plan). */
/** The creator and its card, or null (unknown handle, suspended, no published plan). */
async function lookup(username) {
async function lookup(username) {
if (!USERNAME_RE.test(String(username || ''))) return null;
if (!USERNAME_RE.test(String(username || ''))) return null;
- const c = creators.byUsername(username);
+ const c = await creators.byUsername(username);
const card = c ? await cards.card(c) : null;
const card = c ? await cards.card(c) : null;
return card ? { c, card } : null;
return card ? { c, card } : null;
}
}
@@ -74,16 +74,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -74,16 +74,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
}
}
router.get('/robots.txt', (req, res) => res.type('text/plain').send(`User-agent: *\nDisallow: /me\nDisallow: /dashboard\nDisallow: /auth/\nDisallow: /api/\nDisallow: /embed/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
router.get('/robots.txt', (req, res) => res.type('text/plain').send(`User-agent: *\nDisallow: /me\nDisallow: /dashboard\nDisallow: /auth/\nDisallow: /api/\nDisallow: /embed/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
- router.get('/sitemap.xml', (req, res) => {
- const urls = [`${config.baseUrl}/`, ...creators.listPublic({ limit: 5000 }).map((c) => `${config.baseUrl}/${encodeURIComponent(c.username)}`)];
+ router.get('/sitemap.xml', async (req, res) => {
+ const urls = [`${config.baseUrl}/`, ...(await creators.listPublic({ limit: 5000 })).map((c) => `${config.baseUrl}/${encodeURIComponent(c.username)}`)];
res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${u.replace(/&/g, '&')}</loc></url>`).join('\n')}\n</urlset>\n`);
res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${u.replace(/&/g, '&')}</loc></url>`).join('\n')}\n</urlset>\n`);
});
});
// What shipped on OpenVibe.VIP: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.VIP: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(res, 200, { viewer: req.viewer, canonicalPath: '/updates', title: 'What shipped on OpenVibe.VIP', body: frame.updatesBody({ service: 'vip', siteName: 'OpenVibe.VIP' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));
router.get('/updates', (req, res) => send(res, 200, { viewer: req.viewer, canonicalPath: '/updates', title: 'What shipped on OpenVibe.VIP', body: frame.updatesBody({ service: 'vip', siteName: 'OpenVibe.VIP' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` }));
- router.get('/', (req, res) => send(res, 200, {
+ router.get('/', async (req, res) => send(res, 200, {
active: 'home', viewer: req.viewer, canonicalPath: '/',
active: 'home', viewer: req.viewer, canonicalPath: '/',
- body: pages.home({ creators: creators.listPublic().map(creators.present) }),
+ body: pages.home({ creators: (await creators.listPublic()).map(creators.present) }),
}));
}));
// ── The member's page ────────────────────────────────────
// ── The member's page ────────────────────────────────────
@@ -91,10 +91,10 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -91,10 +91,10 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
try {
try {
const rows = [];
const rows = [];
if (req.viewer) {
if (req.viewer) {
- for (const m of memberships.forMember(req.viewer.subject)) {
- const c = creators.byId(m.creator_id);
+ for (const m of await memberships.forMember(req.viewer.subject)) {
+ const c = await creators.byId(m.creator_id);
const e = c && c.subject ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
const e = c && c.subject ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
- rows.push({ membership: memberships.present(m), creator: creators.present(c), entitlement: e, preferences: memberships.preferences(req.viewer.subject, m.creator_id) });
+ rows.push({ membership: await memberships.present(m), creator: creators.present(c), entitlement: e, preferences: await memberships.preferences(req.viewer.subject, m.creator_id) });
}
}
}
}
send(res, 200, { title: 'Your memberships', active: 'memberships', robots: 'noindex,nofollow', canonicalPath: '/me', viewer: req.viewer, body: pages.memberPage({ viewer: req.viewer, rows, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query }) });
send(res, 200, { title: 'Your memberships', active: 'memberships', robots: 'noindex,nofollow', canonicalPath: '/me', viewer: req.viewer, body: pages.memberPage({ viewer: req.viewer, rows, token: req.viewer ? forms.token(req.viewer.subject) : '', query: req.query }) });
@@ -105,49 +105,49 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -105,49 +105,49 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
back(res, to, 'ok', out.already ? 'That membership was already set to end.' : `Cancelled. Your membership runs until ${pages.fmtDate(out.expires_at)}.`);
back(res, to, 'ok', out.already ? 'That membership was already set to end.' : `Cancelled. Your membership runs until ${pages.fmtDate(out.expires_at)}.`);
}, { backTo: '/me' });
}, { backTo: '/me' });
post('/me/:creatorId/preferences', async (req, res, to) => {
post('/me/:creatorId/preferences', async (req, res, to) => {
- const c = creators.byId(req.params.creatorId);
- if (!c || !memberships.get(req.viewer.subject, c.id)) throw new VipError(404, 'vip.membership_not_found', 'no such membership');
- memberships.setPreferences(req.viewer.subject, c.id, { showBadge: bool(req.body.show_badge) });
+ const c = await creators.byId(req.params.creatorId);
+ if (!c || !await memberships.get(req.viewer.subject, c.id)) throw new VipError(404, 'vip.membership_not_found', 'no such membership');
+ await memberships.setPreferences(req.viewer.subject, c.id, { showBadge: bool(req.body.show_badge) });
back(res, to, 'ok', 'Saved.');
back(res, to, 'ok', 'Saved.');
}, { backTo: '/me' });
}, { backTo: '/me' });
// ── Creator dashboard ────────────────────────────────────
// ── Creator dashboard ────────────────────────────────────
/** The creator the dashboard acts for: the viewer, or the network for staff (?as=network). */
/** The creator the dashboard acts for: the viewer, or the network for staff (?as=network). */
- function dashCreator(req) {
+ async function dashCreator(req) {
const asNetwork = (req.query.as || (req.body && req.body.as)) === 'network';
const asNetwork = (req.query.as || (req.body && req.body.as)) === 'network';
if (asNetwork) {
if (asNetwork) {
if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff manage network plans');
if (!isStaff(req.viewer)) throw new VipError(403, 'vip.not_staff', 'only staff manage network plans');
- return creators.network();
+ return await creators.network();
}
}
- return creators.ensure({ subject: req.viewer.subject, username: req.viewer.username, displayName: req.viewer.name, origin: 'self' });
+ return await creators.ensure({ subject: req.viewer.subject, username: req.viewer.username, displayName: req.viewer.name, origin: 'self' });
}
}
router.get('/dashboard', async (req, res, next) => {
router.get('/dashboard', async (req, res, next) => {
try {
try {
if (!req.viewer) return send(res, 200, { title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', viewer: null, body: pages.dashboard({ viewer: null }) });
if (!req.viewer) return send(res, 200, { title: 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', viewer: null, body: pages.dashboard({ viewer: null }) });
let creator;
let creator;
- try { creator = dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }
+ try { creator = await dashCreator(req); } catch (e) { return send(res, 403, { title: 'Dashboard', robots: 'noindex,nofollow', viewer: req.viewer, body: pages.errorPage({ status: 403, title: 'Not allowed', message: e.detail }) }); }
const isNetwork = creator.kind === 'network';
const isNetwork = creator.kind === 'network';
- const planRows = plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true }).map((p) => plans.present(p, { withVersions: true }));
- const allPerks = perks.list({ creatorId: creator.id }).map((p) => perks.present(p, { withBindings: false }));
- const ownPerks = perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true }).map((p) => perks.present(p));
+ const planRows = (await Promise.all((await plans.list({ creatorId: creator.id, includeDrafts: true, includeArchived: true })).map(async (p) => await plans.present(p, { withVersions: true }))));
+ const allPerks = (await Promise.all((await perks.list({ creatorId: creator.id })).map(async (p) => await perks.present(p, { withBindings: false }))));
+ const ownPerks = (await Promise.all((await perks.list({ creatorId: creator.id, includeNetwork: false, includeRetired: true })).map(async (p) => await perks.present(p))));
let members = [];
let members = [];
let membersSource = 'billing';
let membersSource = 'billing';
if (!isNetwork) {
if (!isNetwork) {
- const byMember = new Map(memberships.forCreator(creator.id).map((m) => [m.member_subject, m]));
+ const byMember = new Map((await memberships.forCreator(creator.id)).map((m) => [m.member_subject, m]));
try {
try {
const out = await billing.listSubscriptions({ streamer: creator.subject, status: 'active' });
const out = await billing.listSubscriptions({ streamer: creator.subject, status: 'active' });
- members = (out.subscriptions || []).map((s) => ({ member: s.subscriber, current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, membership: memberships.present(byMember.get(s.subscriber.id)) }));
+ members = (await Promise.all((out.subscriptions || []).map(async (s) => ({ member: s.subscriber, current_period_end: s.current_period_end, cancel_at_period_end: !!s.cancel_at_period_end, membership: await memberships.present(byMember.get(s.subscriber.id)) }))));
} catch {
} catch {
membersSource = 'projection';
membersSource = 'projection';
- members = entitlements.projectedMembers(creator.subject).map((pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: memberships.present(byMember.get(pr.member_subject)) }));
+ members = (await Promise.all((await entitlements.projectedMembers(creator.subject)).map(async (pr) => ({ member: { type: 'user', id: pr.member_subject }, current_period_end: pr.expires_at, cancel_at_period_end: !!pr.cancel_at_period_end, membership: await memberships.present(byMember.get(pr.member_subject)) }))));
}
}
}
}
send(res, 200, {
send(res, 200, {
title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
body: pages.dashboard({
body: pages.dashboard({
viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource,
viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource,
- rules: isNetwork ? [] : policies.list(creator.id).map(policies.present), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
+ rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,
embeds: !isNetwork && creator.username ? domain.cards.urls(creator) : null,
}),
}),
});
});
@@ -157,64 +157,64 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -157,64 +157,64 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
// Dashboard writes: /dashboard/... for the viewer's own creator, /dashboard/network/... for staff.
// Dashboard writes: /dashboard/... for the viewer's own creator, /dashboard/network/... for staff.
for (const base of ['/dashboard', '/dashboard/network']) {
for (const base of ['/dashboard', '/dashboard/network']) {
const withAs = (req) => { if (base === '/dashboard/network') req.query.as = 'network'; };
const withAs = (req) => { if (base === '/dashboard/network') req.query.as = 'network'; };
- const own = (req, row) => { withAs(req); const c = dashCreator(req); if (!row || row.creator_id !== c.id) throw new VipError(404, 'vip.not_found', 'not found'); return c; };
+ const own = async (req, row) => { withAs(req); const c = await dashCreator(req); if (!row || row.creator_id !== c.id) throw new VipError(404, 'vip.not_found', 'not found'); return c; };
const opts = { backTo: () => (base === '/dashboard/network' ? '/dashboard?as=network' : '/dashboard') };
const opts = { backTo: () => (base === '/dashboard/network' ? '/dashboard?as=network' : '/dashboard') };
post(`${base}/profile`, async (req, res, to) => {
post(`${base}/profile`, async (req, res, to) => {
withAs(req);
withAs(req);
- const c = dashCreator(req);
- creators.update(c.id, { displayName: req.body.display_name, bio: req.body.bio, showMemberCount: c.kind === 'creator' ? bool(req.body.show_member_count) : undefined });
+ const c = await dashCreator(req);
+ await creators.update(c.id, { displayName: req.body.display_name, bio: req.body.bio, showMemberCount: c.kind === 'creator' ? bool(req.body.show_member_count) : undefined });
back(res, to, 'ok', 'Profile saved.');
back(res, to, 'ok', 'Profile saved.');
}, opts);
}, opts);
post(`${base}/plans`, async (req, res, to) => {
post(`${base}/plans`, async (req, res, to) => {
withAs(req);
withAs(req);
- const c = dashCreator(req);
- const plan = plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });
+ const c = await dashCreator(req);
+ const plan = await plans.create({ creatorId: c.id, name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), publish: bool(req.body.publish), actor: req.viewer.subject, traceparent: req.ov.traceparent });
back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}.`);
back(res, to, 'ok', `Plan created${plan.status === 'published' ? ' and published' : ''}.`);
}, opts);
}, opts);
post(`${base}/plans/:id`, async (req, res, to) => {
post(`${base}/plans/:id`, async (req, res, to) => {
- const plan = plans.byId(req.params.id);
- own(req, plan);
- const out = plans.update(plan.id, { name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), changeNote: req.body.change_note, actor: req.viewer.subject, traceparent: req.ov.traceparent });
+ const plan = await plans.byId(req.params.id);
+ await own(req, plan);
+ const out = await plans.update(plan.id, { name: req.body.name, description: req.body.description, benefits: lines(req.body.benefits), perks: list(req.body.perks), changeNote: req.body.change_note, actor: req.viewer.subject, traceparent: req.ov.traceparent });
back(res, to, 'ok', out.unchanged ? 'Nothing changed.' : `Saved as version ${out.version.version}.`);
back(res, to, 'ok', out.unchanged ? 'Nothing changed.' : `Saved as version ${out.version.version}.`);
}, opts);
}, opts);
post(`${base}/plans/:id/publish`, async (req, res, to) => {
post(`${base}/plans/:id/publish`, async (req, res, to) => {
- const plan = plans.byId(req.params.id);
- own(req, plan);
- plans.publish(plan.id, { traceparent: req.ov.traceparent });
+ const plan = await plans.byId(req.params.id);
+ await own(req, plan);
+ await plans.publish(plan.id, { traceparent: req.ov.traceparent });
back(res, to, 'ok', 'Published.');
back(res, to, 'ok', 'Published.');
}, opts);
}, opts);
post(`${base}/plans/:id/archive`, async (req, res, to) => {
post(`${base}/plans/:id/archive`, async (req, res, to) => {
- const plan = plans.byId(req.params.id);
- own(req, plan);
- plans.archive(plan.id);
+ const plan = await plans.byId(req.params.id);
+ await own(req, plan);
+ await plans.archive(plan.id);
back(res, to, 'ok', 'Archived. Current members keep their terms until their membership ends.');
back(res, to, 'ok', 'Archived. Current members keep their terms until their membership ends.');
}, opts);
}, opts);
const bindingLines = (v) => lines(v).map((l) => { const [product, binding] = l.split(/\s+/); return { product, binding }; });
const bindingLines = (v) => lines(v).map((l) => { const [product, binding] = l.split(/\s+/); return { product, binding }; });
post(`${base}/perks`, async (req, res, to) => {
post(`${base}/perks`, async (req, res, to) => {
withAs(req);
withAs(req);
- const c = dashCreator(req);
- perks.create({ creatorId: c.id, key: req.body.key || undefined, name: req.body.name, description: req.body.description, kind: req.body.kind || 'other', bindings: bindingLines(req.body.bindings), actor: req.viewer.subject });
+ const c = await dashCreator(req);
+ await perks.create({ creatorId: c.id, key: req.body.key || undefined, name: req.body.name, description: req.body.description, kind: req.body.kind || 'other', bindings: bindingLines(req.body.bindings), actor: req.viewer.subject });
back(res, to, 'ok', 'Perk created.');
back(res, to, 'ok', 'Perk created.');
}, opts);
}, opts);
post(`${base}/perks/:id`, async (req, res, to) => {
post(`${base}/perks/:id`, async (req, res, to) => {
- const p = perks.byId(req.params.id);
- own(req, p);
- perks.update(p.id, { name: req.body.name, description: req.body.description, status: req.body.status, bindings: bindingLines(req.body.bindings) });
+ const p = await perks.byId(req.params.id);
+ await own(req, p);
+ await perks.update(p.id, { name: req.body.name, description: req.body.description, status: req.body.status, bindings: bindingLines(req.body.bindings) });
back(res, to, 'ok', 'Perk saved.');
back(res, to, 'ok', 'Perk saved.');
}, opts);
}, opts);
if (base === '/dashboard') {
if (base === '/dashboard') {
post(`${base}/rules`, async (req, res, to) => {
post(`${base}/rules`, async (req, res, to) => {
- const c = dashCreator(req);
- policies.set({
+ const c = await dashCreator(req);
+ await policies.set({
creatorId: c.id, resource: { service: req.body.service, type: req.body.type, id: req.body.id }, requirement: req.body.requirement || 'member',
creatorId: c.id, resource: { service: req.body.service, type: req.body.type, id: req.body.id }, requirement: req.body.requirement || 'member',
planId: req.body.plan_id || null, perkKey: req.body.perk_key || null, sensitive: bool(req.body.sensitive), actor: req.viewer.subject,
planId: req.body.plan_id || null, perkKey: req.body.perk_key || null, sensitive: bool(req.body.sensitive), actor: req.viewer.subject,
});
});
back(res, to, 'ok', 'Rule saved.');
back(res, to, 'ok', 'Rule saved.');
}, opts);
}, opts);
post(`${base}/rules/:id/disable`, async (req, res, to) => {
post(`${base}/rules/:id/disable`, async (req, res, to) => {
- const rule = policies.byId(req.params.id);
- own(req, rule);
- policies.disable(rule.id);
+ const rule = await policies.byId(req.params.id);
+ await own(req, rule);
+ await policies.disable(rule.id);
back(res, to, 'ok', 'Rule removed.');
back(res, to, 'ok', 'Rule removed.');
}, opts);
}, opts);
}
}
@@ -224,10 +224,10 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -224,10 +224,10 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
router.get('/:username', async (req, res, next) => {
router.get('/:username', async (req, res, next) => {
try {
try {
if (RESERVED.has(req.params.username.toLowerCase())) return notFound(req, res);
if (RESERVED.has(req.params.username.toLowerCase())) return notFound(req, res);
- const c = creators.byUsername(req.params.username);
+ const c = await creators.byUsername(req.params.username);
if (!c || c.status !== 'active') return notFound(req, res);
if (!c || c.status !== 'active') return notFound(req, res);
if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);
if (c.username !== req.params.username) return res.redirect(301, `/${encodeURIComponent(c.username)}`);
- const list = plans.list({ creatorId: c.id }).map((p) => plans.present(p));
+ const list = (await Promise.all((await plans.list({ creatorId: c.id })).map(async (p) => await plans.present(p))));
const isOwner = !!(req.viewer && req.viewer.subject === c.subject);
const isOwner = !!(req.viewer && req.viewer.subject === c.subject);
const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
const member = req.viewer && !isOwner ? await entitlements.check(req.viewer.subject, c.subject, { mode: 'auto' }) : null;
const name = c.display_name || c.username;
const name = c.display_name || c.username;
@@ -239,21 +239,21 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -239,21 +239,21 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
});
});
} catch (e) { next(e); }
} catch (e) { next(e); }
});
});
- router.get('/:username/plans/:slug', (req, res) => {
- const c = creators.byUsername(req.params.username);
+ router.get('/:username/plans/:slug', async (req, res) => {
+ const c = await creators.byUsername(req.params.username);
if (!c || c.status !== 'active') return notFound(req, res);
if (!c || c.status !== 'active') return notFound(req, res);
- const plan = plans.list({ creatorId: c.id, includeArchived: true }).find((p) => p.slug === req.params.slug);
+ const plan = (await plans.list({ creatorId: c.id, includeArchived: true })).find((p) => p.slug === req.params.slug);
if (!plan) return notFound(req, res);
if (!plan) return notFound(req, res);
- const versions = plans.versions(plan.id).filter((v) => v.published_at).map(plans.presentVersion);
+ const versions = (await Promise.all((await plans.versions(plan.id)).filter((v) => v.published_at).map(plans.presentVersion)));
return send(res, 200, {
return send(res, 200, {
title: `Terms history · ${c.display_name || c.username}`, canonicalPath: `/${c.username}/plans/${plan.slug}`, viewer: req.viewer, robots: 'noindex,follow',
title: `Terms history · ${c.display_name || c.username}`, canonicalPath: `/${c.username}/plans/${plan.slug}`, viewer: req.viewer, robots: 'noindex,follow',
body: pages.planHistory({ creator: creators.present(c), plan, versions }),
body: pages.planHistory({ creator: creators.present(c), plan, versions }),
});
});
});
});
post('/:username/join', async (req, res, to) => {
post('/:username/join', async (req, res, to) => {
- const c = creators.byUsername(req.params.username);
+ const c = await creators.byUsername(req.params.username);
if (!c) throw new VipError(404, 'vip.creator_not_found', 'no such creator');
if (!c) throw new VipError(404, 'vip.creator_not_found', 'no such creator');
- const plan = plans.byId(String(req.body.plan_id || ''));
+ const plan = await plans.byId(String(req.body.plan_id || ''));
if (!plan || plan.creator_id !== c.id) throw new VipError(404, 'vip.plan_not_found', 'that plan is not offered here');
if (!plan || plan.creator_id !== c.id) throw new VipError(404, 'vip.plan_not_found', 'that plan is not offered here');
const out = await checkout.start({
const out = await checkout.start({
member: req.viewer.subject, planId: req.body.plan_id, provider: req.body.provider, autoRenew: bool(req.body.auto_renew),
member: req.viewer.subject, planId: req.body.plan_id, provider: req.body.provider, autoRenew: bool(req.body.auto_renew),
@@ -70,9 +70,9 @@ const { test, run } = harness('api');
@@ -70,9 +70,9 @@ const { test, run } = harness('api');
test('when Billing settles the handed-off checkout, the membership is filed under the version chosen then', async () => {
test('when Billing settles the handed-off checkout, the membership is filed under the version chosen then', async () => {
await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['Badge in chat', 'Monthly stream'] } }); // v2 published meanwhile
await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['Badge in chat', 'Monthly stream'] } }); // v2 published meanwhile
await t.deliverAll(t.billing.pay(buyer.subject, creator.subject, { provider: 'stripe' }).events);
await t.deliverAll(t.billing.pay(buyer.subject, creator.subject, { provider: 'stripe' }).events);
- const m = t.domain.memberships.get(buyer.subject, t.domain.creators.bySubject(creator.subject).id);
+ const m = await t.domain.memberships.get(buyer.subject, (await t.domain.creators.bySubject(creator.subject)).id);
assert.strictEqual(m.origin, 'checkout');
assert.strictEqual(m.origin, 'checkout');
- assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 1);
+ assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 1);
const again = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });
const again = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });
assert.strictEqual(again.status, 409);
assert.strictEqual(again.status, 409);
assert.strictEqual(again.json.code, 'vip.already_member');
assert.strictEqual(again.json.code, 'vip.already_member');
@@ -90,10 +90,10 @@ const { test, run } = harness('api');
@@ -90,10 +90,10 @@ const { test, run } = harness('api');
assert.strictEqual(e.json.status, 'active');
assert.strictEqual(e.json.status, 'active');
assert.strictEqual(e.json.membership.plan_version.version, 2);
assert.strictEqual(e.json.membership.plan_version.version, 2);
// Billing's own event for the same grant arrives later and changes nothing.
// Billing's own event for the same grant arrives later and changes nothing.
- const before = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === payer.subject).length;
+ const before = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === payer.subject).length;
const out = await t.deliverAll(t.billing.state.lastEvents);
const out = await t.deliverAll(t.billing.state.lastEvents);
assert.ok(['unchanged', 'stale_ignored'].includes(out[1].json.outcome), out[1].json.outcome);
assert.ok(['unchanged', 'stale_ignored'].includes(out[1].json.outcome), out[1].json.outcome);
- assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === payer.subject).length, before);
+ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === payer.subject).length, before);
assert.strictEqual(before, 1);
assert.strictEqual(before, 1);
});
});
@@ -116,7 +116,7 @@ const { test, run } = harness('api');
@@ -116,7 +116,7 @@ const { test, run } = harness('api');
});
});
test('cancel goes through Billing (member only) and keeps the paid period', async () => {
test('cancel goes through Billing (member only) and keeps the paid period', async () => {
- const cid = t.domain.creators.bySubject(creator.subject).id;
+ const cid = (await t.domain.creators.bySubject(creator.subject)).id;
const svc = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { cap: ['vip.membership.checkout'] });
const svc = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { cap: ['vip.membership.checkout'] });
assert.strictEqual(svc.status, 403);
assert.strictEqual(svc.status, 403);
const r = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { user: buyer });
const r = await t.call('POST', `/api/v1/memberships/${creator.subject}/cancel`, { user: buyer });
@@ -213,7 +213,7 @@ const { test, run } = harness('api');
@@ -213,7 +213,7 @@ const { test, run } = harness('api');
const other = t.network.newUser('tess', { role: 'streamer' });
const other = t.network.newUser('tess', { role: 'streamer' });
const steal = await t.page(`/dashboard/plans/${plan.id}`, { user: other, method: 'POST', form: { _csrf: t.csrf((await t.page('/dashboard', { user: other })).text), name: 'mine now' } });
const steal = await t.page(`/dashboard/plans/${plan.id}`, { user: other, method: 'POST', form: { _csrf: t.csrf((await t.page('/dashboard', { user: other })).text), name: 'mine now' } });
assert.match(steal.location, /error=/);
assert.match(steal.location, /error=/);
- assert.strictEqual(t.domain.plans.latestVersion(plan.id).version, 3);
+ assert.strictEqual((await t.domain.plans.latestVersion(plan.id)).version, 3);
const net = await t.page('/dashboard?as=network', { user: creator });
const net = await t.page('/dashboard?as=network', { user: creator });
assert.strictEqual(net.status, 403);
assert.strictEqual(net.status, 403);
const anon = await t.page('/dashboard');
const anon = await t.page('/dashboard');
@@ -243,5 +243,5 @@ const { test, run } = harness('api');
@@ -243,5 +243,5 @@ const { test, run } = harness('api');
} finally { await t2.close(); }
} finally { await t2.close(); }
});
});
- await run().finally(() => t.close());
+ await run().finally(async () => await t.close());
})();
})();
@@ -27,7 +27,7 @@ const TTL = 30_000;
@@ -27,7 +27,7 @@ const TTL = 30_000;
await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } });
await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } });
const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:community', cap: ['vip.entitlement.check', 'vip.resource.policy.evaluate'] })}` }) };
const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:community', cap: ['vip.entitlement.check', 'vip.resource.policy.evaluate'] })}` }) };
let vipDown = false;
let vipDown = false;
- const fetchVia = (url, init) => (vipDown ? Promise.reject(new Error('connect ECONNREFUSED')) : fetch(url, init));
+ const fetchVia = async (url, init) => (vipDown ? Promise.reject(new Error('connect ECONNREFUSED')) : await fetch(url, init));
const vip = createVipClient({ baseUrl: t.base, tokenClient, fetch: fetchVia });
const vip = createVipClient({ baseUrl: t.base, tokenClient, fetch: fetchVia });
const newCache = () => createVipCache({ vip, ttlMs: TTL, denyTtlMs: 10_000, unavailableTtlMs: 2_000, now: t.clock.now });
const newCache = () => createVipCache({ vip, ttlMs: TTL, denyTtlMs: 10_000, unavailableTtlMs: 2_000, now: t.clock.now });
const space = { service: 'community', type: 'space', id: 'crew' };
const space = { service: 'community', type: 'space', id: 'crew' };
@@ -38,7 +38,7 @@ const TTL = 30_000;
@@ -38,7 +38,7 @@ const TTL = 30_000;
assert.strictEqual(e.active, d.allow, `entitlement and evaluate agree (${e.status} / ${d.reason})`);
assert.strictEqual(e.active, d.allow, `entitlement and evaluate agree (${e.status} / ${d.reason})`);
return d.allow;
return d.allow;
};
};
- const lastChanged = (m) => t.outboxEvents('vip.membership.changed').filter((e) => e.payload.member.id === m.subject).pop();
+ const lastChanged = async (m) => (await t.outboxEvents('vip.membership.changed')).filter((e) => e.payload.member.id === m.subject).pop();
test('VIP emits vip.membership.changed; a cache given the event stops granting at once', async () => {
test('VIP emits vip.membership.changed; a cache given the event stops granting at once', async () => {
const m = t.network.newUser('m1');
const m = t.network.newUser('m1');
@@ -46,7 +46,7 @@ const TTL = 30_000;
@@ -46,7 +46,7 @@ const TTL = 30_000;
await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
assert.strictEqual(await grants(cache, m), true);
assert.strictEqual(await grants(cache, m), true);
await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);
await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);
- const ev = lastChanged(m);
+ const ev = await lastChanged(m);
assert.strictEqual(ev.payload.active, false);
assert.strictEqual(ev.payload.active, false);
assert.strictEqual(await grants(cache, m), true, 'still the cached yes (the event has not reached the product)');
assert.strictEqual(await grants(cache, m), true, 'still the cached yes (the event has not reached the product)');
assert.strictEqual(cache.handleEvent(ev), true);
assert.strictEqual(cache.handleEvent(ev), true);
@@ -149,5 +149,5 @@ const TTL = 30_000;
@@ -149,5 +149,5 @@ const TTL = 30_000;
assert.deepStrictEqual(cache.bounds, { grantMs: TTL, denyMs: 10_000, unavailableMs: 2_000 });
assert.deepStrictEqual(cache.bounds, { grantMs: TTL, denyMs: 10_000, unavailableMs: 2_000 });
});
});
- await run().finally(() => t.close());
+ await run().finally(async () => await t.close());
})();
})();
@@ -33,7 +33,7 @@ const MIN = 60_000;
@@ -33,7 +33,7 @@ const MIN = 60_000;
assert.strictEqual(e.source, 'projection');
assert.strictEqual(e.source, 'projection');
assert.strictEqual(e.membership.plan_version.version, 1);
assert.strictEqual(e.membership.plan_version.version, 1);
assert.strictEqual(t.billing.entitlementCalls(), calls);
assert.strictEqual(t.billing.entitlementCalls(), calls);
- const changed = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);
+ const changed = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject);
assert.strictEqual(changed.length, 1);
assert.strictEqual(changed.length, 1);
assert.strictEqual(changed[0].payload.active, true);
assert.strictEqual(changed[0].payload.active, true);
assert.strictEqual(changed[0].payload.reason, 'granted');
assert.strictEqual(changed[0].payload.reason, 'granted');
@@ -45,7 +45,7 @@ const MIN = 60_000;
@@ -45,7 +45,7 @@ const MIN = 60_000;
const ent = t.billing.pay(m.subject, creator.subject).events[1];
const ent = t.billing.pay(m.subject, creator.subject).events[1];
assert.strictEqual((await t.deliver(ent)).json.duplicate, false);
assert.strictEqual((await t.deliver(ent)).json.duplicate, false);
assert.strictEqual((await t.deliver(ent)).json.duplicate, true);
assert.strictEqual((await t.deliver(ent)).json.duplicate, true);
- assert.strictEqual(t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject).length, 1);
+ assert.strictEqual((await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject).length, 1);
});
});
test('deliveries must be signed (v2, inside the replay window); only Billing is believed', async () => {
test('deliveries must be signed (v2, inside the replay window); only Billing is believed', async () => {
@@ -56,7 +56,7 @@ const MIN = 60_000;
@@ -56,7 +56,7 @@ const MIN = 60_000;
assert.strictEqual((await t.deliver(ent, { now: Date.now() - 301000 })).status, 401, 'stale v2 (outside the 300 s window): refused');
assert.strictEqual((await t.deliver(ent, { now: Date.now() - 301000 })).status, 401, 'stale v2 (outside the 300 s window): refused');
const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };
const forged = { ...ent, event_id: t.billing.envelope('x.y.z', { type: 'x', id: '1' }, {}).event_id, source: 'live' };
assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');
assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');
- assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);
+ assert.strictEqual(await t.domain.entitlements.getRow(m.subject, creator.subject), null);
const raw = JSON.stringify({ event: ent, seq: 1 });
const raw = JSON.stringify({ event: ent, seq: 1 });
const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: signDeliveryHeaders(raw + ' ', t.EVENTS_SECRET) });
const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: signDeliveryHeaders(raw + ' ', t.EVENTS_SECRET) });
assert.strictEqual(res.status, 401);
assert.strictEqual(res.status, 401);
@@ -71,7 +71,7 @@ const MIN = 60_000;
@@ -71,7 +71,7 @@ const MIN = 60_000;
let e = await check(m);
let e = await check(m);
assert.strictEqual(e.active, true);
assert.strictEqual(e.active, true);
assert.strictEqual(e.cancel_at_period_end, true);
assert.strictEqual(e.cancel_at_period_end, true);
- const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);
+ const ev = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject);
assert.strictEqual(ev[ev.length - 1].payload.reason, 'cancel_scheduled');
assert.strictEqual(ev[ev.length - 1].payload.reason, 'cancel_scheduled');
t.clock.advance(26 * DAY);
t.clock.advance(26 * DAY);
await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);
await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);
@@ -106,7 +106,7 @@ const MIN = 60_000;
@@ -106,7 +106,7 @@ const MIN = 60_000;
assert.strictEqual(e.active, true);
assert.strictEqual(e.active, true);
assert.strictEqual(e.stale, false);
assert.strictEqual(e.stale, false);
assert.strictEqual(Date.parse(e.expires_at), Date.parse(t.billing.entitlement(m.subject, creator.subject).expires_at));
assert.strictEqual(Date.parse(e.expires_at), Date.parse(t.billing.entitlement(m.subject, creator.subject).expires_at));
- const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);
+ const ev = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject);
assert.strictEqual(ev[ev.length - 1].payload.reason, 'renewed');
assert.strictEqual(ev[ev.length - 1].payload.reason, 'renewed');
t.clock.advance(-(30 * DAY + 5 * MIN));
t.clock.advance(-(30 * DAY + 5 * MIN));
});
});
@@ -133,7 +133,7 @@ const MIN = 60_000;
@@ -133,7 +133,7 @@ const MIN = 60_000;
assert.deepStrictEqual(out.map((o) => o.json.outcome), ['doubt', 'changed']);
assert.deepStrictEqual(out.map((o) => o.json.outcome), ['doubt', 'changed']);
const e = await check(m, 'projection');
const e = await check(m, 'projection');
assert.strictEqual(e.active, false);
assert.strictEqual(e.active, false);
- const ev = t.outboxEvents('vip.membership.changed').filter((x) => x.payload.member.id === m.subject);
+ const ev = (await t.outboxEvents('vip.membership.changed')).filter((x) => x.payload.member.id === m.subject);
assert.strictEqual(ev[ev.length - 1].payload.reason, 'refund');
assert.strictEqual(ev[ev.length - 1].payload.reason, 'refund');
assert.strictEqual(ev[ev.length - 1].payload.active, false);
assert.strictEqual(ev[ev.length - 1].payload.active, false);
});
});
@@ -155,7 +155,7 @@ const MIN = 60_000;
@@ -155,7 +155,7 @@ const MIN = 60_000;
const m = t.network.newUser('m10');
const m = t.network.newUser('m10');
await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
t.billing.refund(m.subject, creator.subject); // nothing delivered
t.billing.refund(m.subject, creator.subject); // nothing delivered
- const row = t.domain.entitlements.getRow(m.subject, creator.subject);
+ const row = await t.domain.entitlements.getRow(m.subject, creator.subject);
assert.strictEqual(row.valid_until - row.synced_at, t.config.projection.maxAgeMs);
assert.strictEqual(row.valid_until - row.synced_at, t.config.projection.maxAgeMs);
t.clock.advance(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1);
t.clock.advance(t.config.projection.maxAgeMs + t.config.projection.graceMs + 1);
assert.strictEqual((await check(m, 'projection')).active, false);
assert.strictEqual((await check(m, 'projection')).active, false);
@@ -241,7 +241,7 @@ const MIN = 60_000;
@@ -241,7 +241,7 @@ const MIN = 60_000;
t.clock.advance(t.config.projection.maxAgeMs - MIN);
t.clock.advance(t.config.projection.maxAgeMs - MIN);
const out = await t.domain.entitlements.refreshDue();
const out = await t.domain.entitlements.refreshDue();
assert.ok(out.checked >= 1 && out.ok >= 1);
assert.ok(out.checked >= 1 && out.ok >= 1);
- const row = t.domain.entitlements.getRow(m.subject, creator.subject);
+ const row = await t.domain.entitlements.getRow(m.subject, creator.subject);
assert.strictEqual(row.source, 'billing_check');
assert.strictEqual(row.source, 'billing_check');
assert.ok(row.valid_until > t.clock.now() + 10 * MIN);
assert.ok(row.valid_until > t.clock.now() + 10 * MIN);
});
});
@@ -260,5 +260,5 @@ const MIN = 60_000;
@@ -260,5 +260,5 @@ const MIN = 60_000;
assert.strictEqual(byName.status, 200, byName.text);
assert.strictEqual(byName.status, 200, byName.text);
});
});
- await run().finally(() => t.close());
+ await run().finally(async () => await t.close());
})();
})();
@@ -49,7 +49,9 @@ async function boot(opts = {}) {
@@ -49,7 +49,9 @@ async function boot(opts = {}) {
const config = loadConfig(env);
const config = loadConfig(env);
const logs = [];
const logs = [];
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };
- const app = createApp({ config, now: clock.now, log });
+ // One database per boot (PGlite, or VIP_TEST_STORE=pg: the containers), dropped when the boot closes.
+ const testdb = opts.testdb || await require('./db').testDb();
+ const app = await createApp({ config, db: testdb.db, now: clock.now, log });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
const domain = app.locals.domain;
const domain = app.locals.domain;
@@ -87,18 +89,18 @@ async function boot(opts = {}) {
@@ -87,18 +89,18 @@ async function boot(opts = {}) {
}
}
const csrf = (html) => { const m = /name="_csrf" value="([^"]+)"/.exec(html); return m ? m[1] : null; };
const csrf = (html) => { const m = /name="_csrf" value="([^"]+)"/.exec(html); return m ? m[1] : null; };
- const outboxEvents = (type) => domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => !type || e.event_type === type);
+ const outboxEvents = async (type) => (await domain.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => !type || e.event_type === type);
async function close() {
async function close() {
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
app.locals.outbox.stop();
app.locals.outbox.stop();
await billing.close();
await billing.close();
await network.close();
await network.close();
- try { domain.db.close(); } catch { /* */ }
+ if (!opts.testdb) await testdb.close(); // a caller that handed its own database closes it
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
- return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, csrf, outboxEvents, close, EVENTS_SECRET };
+ return { app, base, config, domain, network, billing, clock, logs, call, deliver, deliverAll, page, csrf, outboxEvents, close, testdb, EVENTS_SECRET };
}
}
/** Tiny test harness: sequential async tests, a summary, exit code. */
/** Tiny test harness: sequential async tests, a summary, exit code. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * VIP_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.VIP_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'vip', max });
+
+module.exports = { testDb, pgAvailable };
@@ -53,15 +53,15 @@ function liveSnapshot() {
@@ -53,15 +53,15 @@ function liveSnapshot() {
const own = (await t.call('POST', '/api/v1/plans', { user: selfmade, body: { name: 'My own plan', publish: true } })).json.plan;
const own = (await t.call('POST', '/api/v1/plans', { user: selfmade, body: { name: 'My own plan', publish: true } })).json.plan;
const doImport = (opts = {}) => importLive(t.domain, { live, billingSource: billingApiSource(t.domain.billing), resolveLiveUsers: identity.resolveLiveUsers, log: null, ...opts });
const doImport = (opts = {}) => importLive(t.domain, { live, billingSource: billingApiSource(t.domain.billing), resolveLiveUsers: identity.resolveLiveUsers, log: null, ...opts });
- const snapshotCounts = () => Object.fromEntries(['vip_creators', 'vip_plans', 'vip_plan_versions', 'vip_perks', 'vip_memberships', 'vip_migration_maps', 'vip_entitlement_projection', 'event_outbox']
- .map((x) => [x, t.domain.db.prepare(`SELECT COUNT(*) AS n FROM ${x}`).get().n]));
+ const snapshotCounts = async () => Object.fromEntries((await Promise.all(['vip_creators', 'vip_plans', 'vip_plan_versions', 'vip_perks', 'vip_memberships', 'vip_migration_maps', 'vip_entitlement_projection', 'event_outbox']
+ .map(async (x) => [x, (await t.domain.db.prepare(`SELECT COUNT(*) AS n FROM ${x}`).get()).n]))));
test('a dry run reports everything and keeps nothing', async () => {
test('a dry run reports everything and keeps nothing', async () => {
- const before = snapshotCounts();
+ const before = await snapshotCounts();
const r = await doImport({ dryRun: true });
const r = await doImport({ dryRun: true });
assert.strictEqual(r.dry_run, true);
assert.strictEqual(r.dry_run, true);
assert.strictEqual(r.counts.plans.created, 2);
assert.strictEqual(r.counts.plans.created, 2);
- assert.deepStrictEqual(snapshotCounts(), before);
+ assert.deepStrictEqual(await snapshotCounts(), before);
});
});
let first;
let first;
@@ -76,38 +76,38 @@ function liveSnapshot() {
@@ -76,38 +76,38 @@ function liveSnapshot() {
assert.deepStrictEqual(first.counts.live_subscriptions, { linked: 3, held: 0, unchanged: 0 });
assert.deepStrictEqual(first.counts.live_subscriptions, { linked: 3, held: 0, unchanged: 0 });
assert.ok(first.holds.some((h) => h.live_user === 4));
assert.ok(first.holds.some((h) => h.live_user === 4));
- const perk = t.domain.perks.byKey('network', 'subscriber-badge');
- assert.deepStrictEqual(t.domain.perks.bindingsOf(perk.id).map((b) => `${b.product}:${b.binding}`).sort(), ['chat:badge', 'live:ai_context', 'live:chat_badge', 'live:powerchat_overlay']);
- const jog = t.domain.creators.bySubject(S[1]);
+ const perk = await t.domain.perks.byKey('network', 'subscriber-badge');
+ assert.deepStrictEqual((await t.domain.perks.bindingsOf(perk.id)).map((b) => `${b.product}:${b.binding}`).sort(), ['chat:badge', 'live:ai_context', 'live:chat_badge', 'live:powerchat_overlay']);
+ const jog = await t.domain.creators.bySubject(S[1]);
assert.strictEqual(jog.username, 'japaneseoldguy');
assert.strictEqual(jog.username, 'japaneseoldguy');
assert.strictEqual(jog.display_name, 'Japanese Old Guy');
assert.strictEqual(jog.display_name, 'Japanese Old Guy');
- const plan = t.domain.plans.purchasable(jog.id);
+ const plan = await t.domain.plans.purchasable(jog.id);
assert.strictEqual(plan.slug, 'channel-subscription');
assert.strictEqual(plan.slug, 'channel-subscription');
assert.strictEqual(plan.latest_version, 1);
assert.strictEqual(plan.latest_version, 1);
- assert.deepStrictEqual(t.domain.plans.versionPerks(plan.current_version_id).map((p) => p.perk_key), ['subscriber-badge']);
- const terms = JSON.parse(t.domain.plans.version(plan.current_version_id).terms);
+ assert.deepStrictEqual((await t.domain.plans.versionPerks(plan.current_version_id)).map((p) => p.perk_key), ['subscriber-badge']);
+ const terms = JSON.parse((await t.domain.plans.version(plan.current_version_id)).terms);
assert.strictEqual(terms.price, 'set and charged by OpenVibe.Billing');
assert.strictEqual(terms.price, 'set and charged by OpenVibe.Billing');
assert.doesNotMatch(JSON.stringify(terms), /\bfree\b|\$0|no ads/i);
assert.doesNotMatch(JSON.stringify(terms), /\bfree\b|\$0|no ads/i);
- const m = t.domain.memberships.get(S[2], jog.id);
+ const m = await t.domain.memberships.get(S[2], jog.id);
assert.strictEqual(m.origin, 'import');
assert.strictEqual(m.origin, 'import');
assert.strictEqual(m.plan_version_id, plan.current_version_id);
assert.strictEqual(m.plan_version_id, plan.current_version_id);
- assert.strictEqual(t.domain.memberships.get(S[3], jog.id), null, 'an expired Billing subscription is not a membership');
- assert.strictEqual(t.domain.plans.purchasable(t.domain.creators.bySubject(S[6]).id).id, own.id, 'the creator\'s own plan is linked, not replaced');
+ assert.strictEqual(await t.domain.memberships.get(S[3], jog.id), null, 'an expired Billing subscription is not a membership');
+ assert.strictEqual((await t.domain.plans.purchasable((await t.domain.creators.bySubject(S[6])).id)).id, own.id, 'the creator\'s own plan is linked, not replaced');
const e = await t.call('GET', `/api/v1/entitlements/check?creator=${S[1]}&mode=projection`, { user: { subject: S[2], username: 'viewer1' } });
const e = await t.call('GET', `/api/v1/entitlements/check?creator=${S[1]}&mode=projection`, { user: { subject: S[2], username: 'viewer1' } });
assert.strictEqual(e.json.status, 'active');
assert.strictEqual(e.json.status, 'active');
- const maps = t.domain.db.prepare("SELECT status, COUNT(*) AS n FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' GROUP BY status").all();
+ const maps = await t.domain.db.prepare("SELECT status, COUNT(*) AS n FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' GROUP BY status").all();
assert.deepStrictEqual(maps, [{ status: 'linked', n: 3 }]);
assert.deepStrictEqual(maps, [{ status: 'linked', n: 3 }]);
- const expiredRow = t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '2'").get();
+ const expiredRow = await t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '2'").get();
assert.strictEqual(expiredRow.target_type, 'billing_subscription');
assert.strictEqual(expiredRow.target_type, 'billing_subscription');
- assert.strictEqual(t.outboxEvents('vip.plan.published').length, 3);
+ assert.strictEqual((await t.outboxEvents('vip.plan.published')).length, 3);
});
});
test('a second run changes nothing', async () => {
test('a second run changes nothing', async () => {
- const before = snapshotCounts();
+ const before = await snapshotCounts();
const again = await doImport();
const again = await doImport();
- assert.deepStrictEqual(snapshotCounts(), before);
+ assert.deepStrictEqual(await snapshotCounts(), before);
assert.deepStrictEqual(again.counts.plans, { created: 0, linked: 0, unchanged: 3 });
assert.deepStrictEqual(again.counts.plans, { created: 0, linked: 0, unchanged: 3 });
assert.deepStrictEqual(again.counts.memberships, { imported: 0, unchanged: 3, excluded: 0 });
assert.deepStrictEqual(again.counts.memberships, { imported: 0, unchanged: 3, excluded: 0 });
assert.deepStrictEqual(again.counts.live_subscriptions, { linked: 0, held: 0, unchanged: 3 });
assert.deepStrictEqual(again.counts.live_subscriptions, { linked: 0, held: 0, unchanged: 3 });
@@ -115,12 +115,12 @@ function liveSnapshot() {
@@ -115,12 +115,12 @@ function liveSnapshot() {
});
});
test('a creator\'s edits after the import are never overwritten', async () => {
test('a creator\'s edits after the import are never overwritten', async () => {
- const jog = t.domain.creators.bySubject(S[1]);
- const plan = t.domain.plans.purchasable(jog.id);
- t.domain.plans.update(plan.id, { name: 'Japanese Old Guy club', actor: S[1] });
+ const jog = await t.domain.creators.bySubject(S[1]);
+ const plan = await t.domain.plans.purchasable(jog.id);
+ await t.domain.plans.update(plan.id, { name: 'Japanese Old Guy club', actor: S[1] });
await doImport();
await doImport();
- assert.strictEqual(t.domain.plans.latestVersion(plan.id).version, 2);
- assert.strictEqual(t.domain.plans.latestVersion(plan.id).name, 'Japanese Old Guy club');
+ assert.strictEqual((await t.domain.plans.latestVersion(plan.id)).version, 2);
+ assert.strictEqual((await t.domain.plans.latestVersion(plan.id)).name, 'Japanese Old Guy club');
});
});
test('a held creator is released once the Network knows them', async () => {
test('a held creator is released once the Network knows them', async () => {
@@ -129,7 +129,7 @@ function liveSnapshot() {
@@ -129,7 +129,7 @@ function liveSnapshot() {
assert.strictEqual(r.counts.creators.released, 1);
assert.strictEqual(r.counts.creators.released, 1);
assert.strictEqual(r.counts.creators.held, 0);
assert.strictEqual(r.counts.creators.held, 0);
assert.strictEqual(r.counts.plans.created, 1);
assert.strictEqual(r.counts.plans.created, 1);
- assert.strictEqual(t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'users' AND source_id = '4'").get().status, 'imported');
+ assert.strictEqual((await t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'users' AND source_id = '4'").get()).status, 'imported');
});
});
test('Billing unreachable: the run says so, holds what it could not reconcile and never invents memberships', async () => {
test('Billing unreachable: the run says so, holds what it could not reconcile and never invents memberships', async () => {
@@ -143,15 +143,15 @@ function liveSnapshot() {
@@ -143,15 +143,15 @@ function liveSnapshot() {
try { r = await doImport(); } finally { t.billing.state.down = false; }
try { r = await doImport(); } finally { t.billing.state.down = false; }
assert.strictEqual(r.ok, false);
assert.strictEqual(r.ok, false);
assert.ok(r.billing_errors.length >= 1);
assert.ok(r.billing_errors.length >= 1);
- assert.strictEqual(t.domain.memberships.get(S[2], t.domain.creators.bySubject(x).id), null);
- const row = t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get();
+ assert.strictEqual(await t.domain.memberships.get(S[2], (await t.domain.creators.bySubject(x)).id), null);
+ const row = await t.domain.db.prepare("SELECT * FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get();
assert.strictEqual(row.status, 'held');
assert.strictEqual(row.status, 'held');
assert.match(row.reason, /Billing was not reachable/);
assert.match(row.reason, /Billing was not reachable/);
const r2 = await doImport();
const r2 = await doImport();
assert.strictEqual(r2.ok, true);
assert.strictEqual(r2.ok, true);
- assert.strictEqual(t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get().status, 'linked');
- assert.ok(t.domain.memberships.get(S[2], t.domain.creators.bySubject(x).id));
+ assert.strictEqual((await t.domain.db.prepare("SELECT status FROM vip_migration_maps WHERE source = 'live' AND source_table = 'subscriptions' AND source_id = '4'").get()).status, 'linked');
+ assert.ok(await t.domain.memberships.get(S[2], (await t.domain.creators.bySubject(x)).id));
});
});
- await run().finally(() => { live.close(); fs.rmSync(snap.dir, { recursive: true, force: true }); return t.close(); });
+ await run().finally(async () => { live.close(); fs.rmSync(snap.dir, { recursive: true, force: true }); return await t.close(); });
})();
})();
@@ -30,7 +30,7 @@ const { test, run } = harness('plans');
@@ -30,7 +30,7 @@ const { test, run } = harness('plans');
assert.strictEqual(plan.billing_kind, 'channel_subscription');
assert.strictEqual(plan.billing_kind, 'channel_subscription');
assert.strictEqual(plan.current_version.version, 1);
assert.strictEqual(plan.current_version.version, 1);
assert.deepStrictEqual(plan.current_version.perks.map((p) => p.key), ['emotes', 'supporter-role']);
assert.deepStrictEqual(plan.current_version.perks.map((p) => p.key), ['emotes', 'supporter-role']);
- const ev = t.outboxEvents('vip.plan.published');
+ const ev = await t.outboxEvents('vip.plan.published');
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev[0].subject.id, plan.id);
assert.strictEqual(ev[0].subject.id, plan.id);
assert.strictEqual(ev[0].payload.version, 1);
assert.strictEqual(ev[0].payload.version, 1);
@@ -39,29 +39,29 @@ const { test, run } = harness('plans');
@@ -39,29 +39,29 @@ const { test, run } = harness('plans');
test('a member who joins under v1 is filed under v1', async () => {
test('a member who joins under v1 is filed under v1', async () => {
await t.deliverAll(t.billing.pay(early.subject, creator.subject).events);
await t.deliverAll(t.billing.pay(early.subject, creator.subject).events);
- const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);
+ const m = await t.domain.memberships.get(early.subject, (await t.domain.creators.bySubject(creator.subject)).id);
assert.strictEqual(m.plan_version_id, plan.current_version.id);
assert.strictEqual(m.plan_version_id, plan.current_version.id);
});
});
test('editing a published plan creates v2, publishes it, and never touches v1', async () => {
test('editing a published plan creates v2, publishes it, and never touches v1', async () => {
- const before = t.domain.plans.version(plan.current_version.id);
+ const before = await t.domain.plans.version(plan.current_version.id);
const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'Inner circle', perks: ['supporter-role'], benefits: ['A role'], change_note: 'emotes moved to a separate plan' } });
const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'Inner circle', perks: ['supporter-role'], benefits: ['A role'], change_note: 'emotes moved to a separate plan' } });
assert.strictEqual(res.status, 200, res.text);
assert.strictEqual(res.status, 200, res.text);
assert.strictEqual(res.json.version.version, 2);
assert.strictEqual(res.json.version.version, 2);
assert.strictEqual(res.json.plan.current_version.version, 2);
assert.strictEqual(res.json.plan.current_version.version, 2);
- const after = t.domain.plans.version(plan.current_version.id);
+ const after = await t.domain.plans.version(plan.current_version.id);
assert.deepStrictEqual(after, before, 'v1 row unchanged');
assert.deepStrictEqual(after, before, 'v1 row unchanged');
const versions = await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null });
const versions = await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null });
assert.deepStrictEqual(versions.json.versions.map((v) => v.version), [2, 1]);
assert.deepStrictEqual(versions.json.versions.map((v) => v.version), [2, 1]);
assert.deepStrictEqual(versions.json.versions[1].perks.map((p) => p.key), ['emotes', 'supporter-role']);
assert.deepStrictEqual(versions.json.versions[1].perks.map((p) => p.key), ['emotes', 'supporter-role']);
- assert.strictEqual(t.outboxEvents('vip.plan.published').length, 2);
+ assert.strictEqual((await t.outboxEvents('vip.plan.published')).length, 2);
});
});
- test('the database refuses to rewrite or delete a version', () => {
- assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_versions SET name = 'rewritten' WHERE id = ?").run(plan.current_version.id), /immutable/);
- assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_versions SET terms = '{}' WHERE id = ?").run(plan.current_version.id), /immutable/);
- assert.throws(() => t.domain.db.prepare('DELETE FROM vip_plan_versions WHERE id = ?').run(plan.current_version.id), /immutable/);
- assert.throws(() => t.domain.db.prepare("UPDATE vip_plan_perks SET perk_name = 'x' WHERE plan_version_id = ?").run(plan.current_version.id), /immutable/);
+ test('the database refuses to rewrite or delete a version', async () => {
+ await assert.rejects(t.domain.db.prepare("UPDATE vip_plan_versions SET name = 'rewritten' WHERE id = ?").run(plan.current_version.id), /immutable/);
+ await assert.rejects(t.domain.db.prepare("UPDATE vip_plan_versions SET terms = '{}' WHERE id = ?").run(plan.current_version.id), /immutable/);
+ await assert.rejects(t.domain.db.prepare('DELETE FROM vip_plan_versions WHERE id = ?').run(plan.current_version.id), /immutable/);
+ await assert.rejects(t.domain.db.prepare("UPDATE vip_plan_perks SET perk_name = 'x' WHERE plan_version_id = ?").run(plan.current_version.id), /immutable/);
});
});
test('an edit that changes nothing creates no version', async () => {
test('an edit that changes nothing creates no version', async () => {
@@ -71,7 +71,7 @@ const { test, run } = harness('plans');
@@ -71,7 +71,7 @@ const { test, run } = harness('plans');
});
});
test('renaming a perk does not change published terms', async () => {
test('renaming a perk does not change published terms', async () => {
- const perk = t.domain.perks.byKey(t.domain.creators.bySubject(creator.subject).id, 'emotes');
+ const perk = await t.domain.perks.byKey((await t.domain.creators.bySubject(creator.subject)).id, 'emotes');
await t.call('PATCH', `/api/v1/perks/${perk.id}`, { user: creator, body: { name: 'Renamed emotes' } });
await t.call('PATCH', `/api/v1/perks/${perk.id}`, { user: creator, body: { name: 'Renamed emotes' } });
const v1 = (await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null })).json.versions[1];
const v1 = (await t.call('GET', `/api/v1/plans/${plan.id}/versions`, { token: null })).json.versions[1];
assert.strictEqual(v1.perks.find((p) => p.key === 'emotes').name, 'Members emotes');
assert.strictEqual(v1.perks.find((p) => p.key === 'emotes').name, 'Members emotes');
@@ -79,13 +79,13 @@ const { test, run } = harness('plans');
@@ -79,13 +79,13 @@ const { test, run } = harness('plans');
test('old members keep old terms; new members get the new version', async () => {
test('old members keep old terms; new members get the new version', async () => {
await t.deliverAll(t.billing.pay(late.subject, creator.subject).events);
await t.deliverAll(t.billing.pay(late.subject, creator.subject).events);
- const cid = t.domain.creators.bySubject(creator.subject).id;
- const mEarly = t.domain.memberships.get(early.subject, cid);
- const mLate = t.domain.memberships.get(late.subject, cid);
- assert.strictEqual(t.domain.plans.version(mEarly.plan_version_id).version, 1);
- assert.strictEqual(t.domain.plans.version(mLate.plan_version_id).version, 2);
- assert.deepStrictEqual(t.domain.memberships.perkKeys(mEarly), ['emotes', 'supporter-role']);
- assert.deepStrictEqual(t.domain.memberships.perkKeys(mLate), ['supporter-role']);
+ const cid = (await t.domain.creators.bySubject(creator.subject)).id;
+ const mEarly = await t.domain.memberships.get(early.subject, cid);
+ const mLate = await t.domain.memberships.get(late.subject, cid);
+ assert.strictEqual((await t.domain.plans.version(mEarly.plan_version_id)).version, 1);
+ assert.strictEqual((await t.domain.plans.version(mLate.plan_version_id)).version, 2);
+ assert.deepStrictEqual(await t.domain.memberships.perkKeys(mEarly), ['emotes', 'supporter-role']);
+ assert.deepStrictEqual(await t.domain.memberships.perkKeys(mLate), ['supporter-role']);
const status = await t.call('GET', `/api/v1/memberships/${early.subject}`, { user: early });
const status = await t.call('GET', `/api/v1/memberships/${early.subject}`, { user: early });
assert.strictEqual(status.status, 200);
assert.strictEqual(status.status, 200);
assert.strictEqual(status.json.memberships[0].plan_version.version, 1);
assert.strictEqual(status.json.memberships[0].plan_version.version, 1);
@@ -95,8 +95,8 @@ const { test, run } = harness('plans');
@@ -95,8 +95,8 @@ const { test, run } = harness('plans');
test('a renewal keeps the version the membership was bought under', async () => {
test('a renewal keeps the version the membership was bought under', async () => {
t.clock.advance(29 * DAY);
t.clock.advance(29 * DAY);
await t.deliverAll(t.billing.pay(early.subject, creator.subject).events); // reason: renewed
await t.deliverAll(t.billing.pay(early.subject, creator.subject).events); // reason: renewed
- const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);
- assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 1);
+ const m = await t.domain.memberships.get(early.subject, (await t.domain.creators.bySubject(creator.subject)).id);
+ assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 1);
});
});
test('a new purchase after the membership lapsed takes the current version', async () => {
test('a new purchase after the membership lapsed takes the current version', async () => {
@@ -106,8 +106,8 @@ const { test, run } = harness('plans');
@@ -106,8 +106,8 @@ const { test, run } = harness('plans');
await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);
await t.deliverAll(t.billing.expire(sub.id, 'canceled').events);
await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['A role', 'Monthly Q&A'], change_note: 'added Q&A' } });
await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { benefits: ['A role', 'Monthly Q&A'], change_note: 'added Q&A' } });
await t.deliverAll(t.billing.pay(late.subject, creator.subject).events); // reason: granted
await t.deliverAll(t.billing.pay(late.subject, creator.subject).events); // reason: granted
- const m = t.domain.memberships.get(late.subject, t.domain.creators.bySubject(creator.subject).id);
- assert.strictEqual(t.domain.plans.version(m.plan_version_id).version, 3);
+ const m = await t.domain.memberships.get(late.subject, (await t.domain.creators.bySubject(creator.subject)).id);
+ assert.strictEqual((await t.domain.plans.version(m.plan_version_id)).version, 3);
});
});
test('Billing sells one channel subscription per creator: a second published plan is refused', async () => {
test('Billing sells one channel subscription per creator: a second published plan is refused', async () => {
@@ -123,7 +123,7 @@ const { test, run } = harness('plans');
@@ -123,7 +123,7 @@ const { test, run } = harness('plans');
});
});
test('archived plans keep their members on their terms and cannot be edited', async () => {
test('archived plans keep their members on their terms and cannot be edited', async () => {
- const m = t.domain.memberships.get(early.subject, t.domain.creators.bySubject(creator.subject).id);
+ const m = await t.domain.memberships.get(early.subject, (await t.domain.creators.bySubject(creator.subject)).id);
assert.strictEqual(m.plan_id, plan.id);
assert.strictEqual(m.plan_id, plan.id);
const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'x' } });
const res = await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { name: 'x' } });
assert.strictEqual(res.status, 409);
assert.strictEqual(res.status, 409);
@@ -156,12 +156,12 @@ const { test, run } = harness('plans');
@@ -156,12 +156,12 @@ const { test, run } = harness('plans');
});
});
test('drafts are hidden from the public', async () => {
test('drafts are hidden from the public', async () => {
- const drafts = t.domain.plans.list({ creatorId: t.domain.creators.bySubject(creator.subject).id, includeDrafts: true }).filter((p) => p.status === 'draft');
+ const drafts = (await t.domain.plans.list({ creatorId: (await t.domain.creators.bySubject(creator.subject)).id, includeDrafts: true })).filter((p) => p.status === 'draft');
const res = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { token: null });
const res = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { token: null });
assert.strictEqual(res.status, 404);
assert.strictEqual(res.status, 404);
const own = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { user: creator });
const own = await t.call('GET', `/api/v1/plans/${drafts[0].id}`, { user: creator });
assert.strictEqual(own.status, 200);
assert.strictEqual(own.status, 200);
});
});
- await run().finally(() => t.close());
+ await run().finally(async () => await t.close());
})();
})();
@@ -230,5 +230,5 @@ const { test, run } = harness('policies');
@@ -230,5 +230,5 @@ const { test, run } = harness('policies');
assert.strictEqual((await garbage.evaluate({ subject: member.subject, resource: res })).allow, false, 'only a literal true allows');
assert.strictEqual((await garbage.evaluate({ subject: member.subject, resource: res })).allow, false, 'only a literal true allows');
});
});
- await run().finally(() => t.close());
+ await run().finally(async () => await t.close());
})();
})();