Files
Felitendo c1c478bf25 feat: make private mode an account rather than a code
Private mode asked for a six-digit code, minted per person and traded for a pass
that a browser kept in a cookie and the app in its keychain. Accounts arrived
later, for the boards and the setup backup, and from that point the code was a
second credential doing a worse version of the same job. It said you may look and
nothing about who was looking, so it could not carry what somebody wrote, it
could not be signed out of from another device, and six digits is a space small
enough that it had to be defended by a throttle doing most of the work.

So the account is the credential now. The landing page asks for a name and a
password instead of drawing a keypad, the feed and the token downloads want the
same session, and the store's own screen for being shut out is the sign-in it
already had. One credential, on both sides of the wire, and the same one
somebody needs before they can post or keep a backup.

What changes on the way in is who may register. Public: anybody makes an account
from the app, as before. Private: an operator mints an invitation and sends the
link, and opening it is the only way a new account happens. One link, one
account, spent the moment it works, so a forwarded link is not a second way in.
The redeem is filtered on being unspent, so two people racing one link end with
one account and one refusal. It is shown once, it can be withdrawn while nobody
has used it, and it expires if nobody does.

The token is 128 bits and stored as a digest, which is the opposite of the choice
the codes had to make: a code was a candidate list somebody could work through
offline, so it needed a slow hash and could not be looked up, where a token is no
list at all.

Withdrawing a link is deliberately not the same action as removing somebody. Once
it has been redeemed there is an account, and taking that away is muting or
deleting it on the board's own screen, where the things they wrote are in view.

This locks out every device a code had let in, and there is no way around it: a
pass records that a code was answered on some device and nothing about who held
it, which is exactly the gap accounts close. The two tables are dropped in
pre-push.sql, where every destructive change here is written out. A public
deployment is untouched.

The throttle stays and gains a second half. The ladder against the address is
what it was, minus a ceiling that came down when the codes did; the ladder
against the name being signed in to already existed for accounts and now bounds
somebody working through one person's password from anywhere they like. Only a
wrong answer climbs either, so being told a name is taken is not a step towards
a lockout.

Both consoles move together, as they have to: the invitations, the accounts and
the devices are the same five sections in the same order in the dashboard and on
the phone. The alert for a new device compares a moment rather than an id, since
a session's key is the digest of a token and there is no id to compare.
2026-09-01 09:27:34 +02:00

389 lines
15 KiB
SQL

-- Everything `prisma db push` refuses to do to a database that already has rows.
--
-- Production deploys by pushing the schema (see `Dockerfile`), and `db push`
-- stops the moment a change *could* destroy data: a dropped column, a dropped
-- table, a unique index over rows it has not seen. It stops for the whole push,
-- so one refused drop leaves every other change unapplied and the app and the
-- worker restart-loop on
--
-- Error: Use the --accept-data-loss flag to ignore the data loss warnings
--
-- `--accept-data-loss` is not the answer, because it does not answer the
-- question: it would wave through every future drop, including the ones nobody
-- meant to make. Instead each such change is written out here in full, once, and
-- applied before the push. The push then only ever sees work it considers safe,
-- and a destructive change is something somebody wrote down and reviewed rather
-- than something a flag allowed through at three in the morning.
--
-- Rules for anything added here:
--
-- • **Idempotent.** Both containers run this on every boot, so every block has
-- to be a no-op the second time.
-- • **Silent on a fresh database.** Nothing exists yet on a first boot and
-- `db push` creates all of it, so every block first checks that what it is
-- about to change is actually there.
-- • **Prisma's own names.** Indexes and constraints created here must be named
-- exactly what `db push` would have named them, or the push reads them as
-- drift and recreates them on the spot.
-- • **Entries stay.** A deployment that skipped four releases has to be able to
-- run the whole file, in order, and arrive where a fresh one starts.
--
-- The matching `prisma/migrations/…` entry stays authoritative for the *shape*
-- of each change; this file is that shape rewritten to survive being run against
-- a database that may already be half-way there.
-- The app and the worker boot together and both run this. The lock makes the
-- second one wait instead of racing the first through the same CREATEs. It is
-- session-scoped, so it is released when the script's connection closes; the
-- number is arbitrary and only has to stay the same between the two containers.
SELECT pg_advisory_lock(8710571);
-- 20260726120000_add_local_ipa_storage — local disk storage for IPA files.
--
-- `download_token` is NULL on every row that predates it and Postgres counts
-- NULLs in a unique index as distinct, so the index cannot collide. `db push`
-- warns anyway, because "unique index over an existing table" is as far as its
-- reasoning goes.
DO $$
BEGIN
IF to_regclass('"downloaded_ipas"') IS NULL THEN
RETURN;
END IF;
ALTER TABLE "downloaded_ipas" ADD COLUMN IF NOT EXISTS "storage_path" TEXT;
ALTER TABLE "downloaded_ipas" ADD COLUMN IF NOT EXISTS "download_token" TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS "downloaded_ipas_download_token_key"
ON "downloaded_ipas"("download_token");
CREATE INDEX IF NOT EXISTS "downloaded_ipas_storage_path_idx"
ON "downloaded_ipas"("storage_path");
END
$$;
-- 20260806160000_add_feedback_identities — the columns behind a registered name.
--
-- Split from the backfill below so the statements that read these columns are
-- parsed after they exist.
DO $$
BEGIN
IF to_regclass('"feedback_authors"') IS NULL THEN
RETURN;
END IF;
-- Checked by hand rather than with ADD COLUMN IF NOT EXISTS: `SERIAL` is
-- expanded into a sequence plus a default before the existence check is
-- reached, and a second boot must not leave an orphan sequence behind.
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'feedback_authors'
AND column_name = 'handle_number'
) THEN
ALTER TABLE "feedback_authors" ADD COLUMN "handle_number" SERIAL;
END IF;
ALTER TABLE "feedback_authors"
ADD COLUMN IF NOT EXISTS "name_key" TEXT,
ADD COLUMN IF NOT EXISTS "pin_hash" TEXT,
ADD COLUMN IF NOT EXISTS "pin_failures" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "pin_locked_until" TIMESTAMP(3);
END
$$;
DO $$
BEGIN
IF to_regclass('"feedback_authors"') IS NULL THEN
RETURN;
END IF;
-- Names that existed before registration did. They keep the name they are
-- posting under and hold it against anyone else claiming it; their owner is
-- asked for a PIN the first time they open the name screen. The expression is
-- `displayNameKey` from `lib/feedback.ts`, spelled in SQL.
UPDATE "feedback_authors"
SET "name_key" = trim(regexp_replace(lower("display_name"), '[^a-z0-9]+', ' ', 'g'))
WHERE "name_key" IS NULL
AND "display_name" IS NOT NULL
AND trim(regexp_replace(lower("display_name"), '[^a-z0-9]+', ' ', 'g')) <> '';
-- Two installs that picked the same name back when nothing stopped them: the
-- older row keeps it, the rest go back to their anonymous handle rather than
-- the unique index below failing and taking the whole boot with it. A no-op
-- on every later run, since the index makes the duplicate impossible.
UPDATE "feedback_authors" a
SET "display_name" = NULL, "name_key" = NULL
WHERE a."name_key" IS NOT NULL
AND EXISTS (
SELECT 1 FROM "feedback_authors" b
WHERE b."name_key" = a."name_key"
AND (b."created_at" < a."created_at"
OR (b."created_at" = a."created_at" AND b."install_id" < a."install_id"))
);
CREATE UNIQUE INDEX IF NOT EXISTS "feedback_authors_handle_number_key"
ON "feedback_authors"("handle_number");
CREATE UNIQUE INDEX IF NOT EXISTS "feedback_authors_name_key_key"
ON "feedback_authors"("name_key");
END
$$;
-- 20260806190000_add_feedback_votes — voting replaces reacting.
--
-- The vote table is created here rather than left to the push because the rows
-- of the table being dropped have to land somewhere first.
DO $$
BEGIN
IF to_regclass('"feedback_tickets"') IS NULL THEN
RETURN;
END IF;
IF to_regclass('"feedback_votes"') IS NOT NULL THEN
RETURN;
END IF;
CREATE TABLE "feedback_votes" (
"id" SERIAL NOT NULL,
"ticket_id" INTEGER NOT NULL,
"comment_id" INTEGER,
"install_id" TEXT NOT NULL,
"value" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "feedback_votes_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "feedback_votes_ticket_id_comment_id_idx"
ON "feedback_votes"("ticket_id", "comment_id");
CREATE INDEX "feedback_votes_install_id_idx" ON "feedback_votes"("install_id");
ALTER TABLE "feedback_votes" ADD CONSTRAINT "feedback_votes_ticket_id_fkey"
FOREIGN KEY ("ticket_id") REFERENCES "feedback_tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "feedback_votes" ADD CONSTRAINT "feedback_votes_comment_id_fkey"
FOREIGN KEY ("comment_id") REFERENCES "feedback_comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END
$$;
-- The two emoji that were already votes carry over; the other six were opinions
-- with no ranking behind them and go with the table. The reaction table's own
-- existence is the guard: once it is dropped this block never runs again.
DO $$
BEGIN
IF to_regclass('"feedback_reactions"') IS NULL THEN
RETURN;
END IF;
-- One row per install per target. An install that managed to file two `+1`s
-- on one ticket was always counted once, and `DISTINCT ON` keeps that true;
-- an install that reacted both 👍 and 👎 ends up with its newest one.
INSERT INTO "feedback_votes" ("ticket_id", "comment_id", "install_id", "value", "created_at", "updated_at")
SELECT DISTINCT ON ("ticket_id", "comment_id", "install_id")
"ticket_id",
"comment_id",
"install_id",
CASE WHEN "emoji" = '+1' THEN 1 ELSE -1 END,
"created_at",
"created_at"
FROM "feedback_reactions"
WHERE "emoji" IN ('+1', '-1')
ORDER BY "ticket_id", "comment_id", "install_id", "created_at" DESC;
DROP TABLE "feedback_reactions";
END
$$;
DO $$
BEGIN
IF to_regclass('"feedback_tickets"') IS NULL THEN
RETURN;
END IF;
ALTER TABLE "feedback_tickets"
ADD COLUMN IF NOT EXISTS "vote_up" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "vote_down" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "vote_score" INTEGER NOT NULL DEFAULT 0;
-- `feedback_tickets_hidden_upvotes_idx` goes with the column it was on.
CREATE INDEX IF NOT EXISTS "feedback_tickets_hidden_vote_score_idx"
ON "feedback_tickets"("hidden", "vote_score");
END
$$;
-- The counters are seeded from the votes exactly once, while the column they
-- replace is still there. Later boots must not touch them: they are recomputed
-- on every write by `refreshTicketCounters`, and rewriting them here would undo
-- whatever the board did between two deploys.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'feedback_tickets'
AND column_name = 'upvotes'
) THEN
RETURN;
END IF;
UPDATE "feedback_tickets" t
SET "vote_up" = v."up", "vote_down" = v."down", "vote_score" = v."up" - v."down"
FROM (
SELECT
"ticket_id",
count(*) FILTER (WHERE "value" > 0) AS "up",
count(*) FILTER (WHERE "value" < 0) AS "down"
FROM "feedback_votes"
WHERE "comment_id" IS NULL
GROUP BY "ticket_id"
) v
WHERE t."id" = v."ticket_id";
ALTER TABLE "feedback_tickets" DROP COLUMN "upvotes";
END
$$;
-- 20260812090000_tweak_request_group_key — seed the wish grouping key.
--
-- A tweak request may now be filed without a source, so the board can no longer
-- group on `source_id`: every sourceless row would collapse into one wish. It
-- groups on `group_key` instead, which is the source when there is one and the
-- normalised tweak name when there is not.
--
-- Every row that predates the column has a source, so its key is exactly that
-- source. `db push` adds the column with an empty-string default; this is what
-- turns those defaults into the value the code would have written.
--
-- Idempotent by the `group_key = ''` filter: a second run finds nothing left to
-- do. It deliberately does not touch rows that already have a key.
DO $$
BEGIN
IF to_regclass('"tweak_requests"') IS NULL THEN
RETURN;
END IF;
-- Written by `db push` on the same boot, so on a fresh database this runs
-- before the column exists and has nothing to seed either way.
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'tweak_requests'
AND column_name = 'group_key'
) THEN
RETURN;
END IF;
UPDATE "tweak_requests"
SET "group_key" = "source_id"
WHERE "group_key" = '' AND "source_id" IS NOT NULL;
END
$$;
-- Accounts on the boards: a real password, a picture, and a session per device.
--
-- `password_hash` and `avatar_path` are NULL on every row that predates them,
-- which is exactly what "this install never registered" means to the code, so
-- there is nothing to backfill. The unique index over `avatar_path` cannot
-- collide for the same reason `download_token`'s could not: Postgres counts
-- NULLs in a unique index as distinct. `db push` warns anyway, because "unique
-- index over an existing table" is as far as its reasoning goes.
--
-- `app_sessions` is created here rather than left to the push so that the
-- foreign key onto `feedback_authors` exists before anything tries to sign in
-- against a half-applied schema.
DO $$
BEGIN
IF to_regclass('"feedback_authors"') IS NULL THEN
RETURN;
END IF;
ALTER TABLE "feedback_authors" ADD COLUMN IF NOT EXISTS "password_hash" TEXT;
ALTER TABLE "feedback_authors" ADD COLUMN IF NOT EXISTS "avatar_path" TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS "feedback_authors_avatar_path_key"
ON "feedback_authors"("avatar_path");
CREATE TABLE IF NOT EXISTS "app_sessions" (
"token_hash" TEXT NOT NULL,
"install_id" TEXT NOT NULL,
"device_name" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"last_seen_at" TIMESTAMP(3),
CONSTRAINT "app_sessions_pkey" PRIMARY KEY ("token_hash")
);
CREATE INDEX IF NOT EXISTS "app_sessions_install_id_idx"
ON "app_sessions"("install_id");
-- Named what `db push` would name it, or the push reads it as drift and
-- recreates it on the spot.
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema = current_schema()
AND constraint_name = 'app_sessions_install_id_fkey'
) THEN
ALTER TABLE "app_sessions"
ADD CONSTRAINT "app_sessions_install_id_fkey"
FOREIGN KEY ("install_id") REFERENCES "feedback_authors"("install_id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END
$$;
-- The setup backup that hangs off an account.
--
-- Nothing to backfill: an account with no row simply has no backup, which is
-- what the route answers. Created here rather than left to the push so the
-- foreign key onto `feedback_authors` exists before anything writes one.
DO $$
BEGIN
IF to_regclass('"feedback_authors"') IS NULL THEN
RETURN;
END IF;
CREATE TABLE IF NOT EXISTS "app_backups" (
"install_id" TEXT NOT NULL,
"payload" TEXT NOT NULL,
"format_version" INTEGER NOT NULL,
"device_name" TEXT,
"bytes" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "app_backups_pkey" PRIMARY KEY ("install_id")
);
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema = current_schema()
AND constraint_name = 'app_backups_install_id_fkey'
) THEN
ALTER TABLE "app_backups"
ADD CONSTRAINT "app_backups_install_id_fkey"
FOREIGN KEY ("install_id") REFERENCES "feedback_authors"("install_id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END
$$;
-- Private mode became account-based, so the codes and the passes they opened go.
--
-- A six-digit code said "you may look" and an account says who you are, and once
-- both existed the code was a second credential doing a worse version of the
-- same job: it could not carry what somebody wrote, it could not be signed out
-- of, and it was one more secret to hand over. `registration_invites` replaced
-- it, and what it hands out is an account rather than a way past the door.
--
-- **This locks out every device that was let in by a code.** There is no way to
-- turn a pass into an account: a pass says a code was answered on some device
-- and nothing about who was holding it, which is exactly the gap accounts close.
-- So on a private deployment everybody signs in once more, with an invitation
-- from the operator. On a public one nothing here is reachable and nothing
-- changes.
--
-- Passes before codes: the foreign key points that way.
DROP TABLE IF EXISTS "access_passes";
DROP TABLE IF EXISTS "access_codes";
SELECT pg_advisory_unlock(8710571);