Skip to content

Commit 1b1af00

Browse files
wilhel1812wilhel1812
andauthored
feat: remove closed-beta approval gating, add username setup flow (#819)
- Auto-approve all non-revoked users on registration and migrate existing pending users - Add blocking UsernameSetupModal for new users before cloud sync onboarding - Strip pending-approval UI from admin, profile, and preferences sections - Update REGISTRATION_MODE to 'open' in staging/prod configs - Add username_set_at schema column and migration - Add dev:check/dev:stop scripts and AGENTS.md rule for local server cleanup Co-authored-by: wilhel1812 <wilhelm@linksim.link>
1 parent 8d2c910 commit 1b1af00

21 files changed

Lines changed: 361 additions & 156 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
- Local run reliability:
9191
- Restart local server whenever runtime/config/env changes can affect behavior.
9292
- Re-verify affected flows after restart before marking work as done.
93+
- Do not leave local dev/watch servers running after a pass. Before handing back, run `npm run dev:check`; if it reports a LinkSim dev/watch server, run `npm run dev:stop` unless the user explicitly asked to keep it running.
9394
- Production preflight checklist (required before `deploy:prod:main`):
9495
- `npm run test`
9596
- `npm run build:bundle`
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
PRAGMA foreign_keys = OFF;
2+
3+
ALTER TABLE users ADD COLUMN username_set_at TEXT;
4+
5+
UPDATE users
6+
SET username_set_at = COALESCE(updated_at, created_at)
7+
WHERE COALESCE(TRIM(username), '') != '';
8+
9+
UPDATE users
10+
SET is_approved = 1,
11+
approved_at = COALESCE(approved_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
12+
approved_by_user_id = COALESCE(approved_by_user_id, 'system:open-registration'),
13+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
14+
WHERE is_admin = 0
15+
AND is_moderator = 0
16+
AND is_approved = 0
17+
AND (approved_by_user_id IS NULL OR approved_by_user_id NOT LIKE 'revoked:%');
18+
19+
PRAGMA foreign_keys = ON;

db/schema.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
44
id TEXT PRIMARY KEY,
55
username TEXT,
66
email TEXT,
7+
username_set_at TEXT,
78
bio TEXT,
89
access_request_note TEXT,
910
idp_email TEXT,

docs/access-policy-templates.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,16 @@ Use this profile when anonymous users must be able to open shared Simulation dee
3232
- Keep `/api/public-simulation` reachable without Access challenge.
3333

3434
### App authorization model
35-
- Keep `REGISTRATION_MODE=approval_required`.
35+
- Keep `REGISTRATION_MODE=open`.
3636
- Treat Access as identity proof for signed-in users.
3737
- Treat LinkSim visibility/role checks as the data authorization source.
3838
- Anonymous deep-link users must only load the shared/public Simulation bundle resolved by deep link.
3939
- Guest mode must not expose library browsing/discovery of unrelated objects.
4040

41-
### LinkSim app-level approval
42-
- Keep `REGISTRATION_MODE=approval_required`.
41+
### LinkSim app-level authorization
42+
- Keep `REGISTRATION_MODE=open`.
4343
- Cloudflare Access answers “who can sign in”.
44-
- LinkSim approval answers “who can use simulation features”.
44+
- LinkSim visibility and role checks answer “who can use each simulation feature”.
4545

4646
## Hardened profile (optional)
4747
- Restrict to one IdP per environment.
@@ -51,15 +51,15 @@ Use this profile when anonymous users must be able to open shared Simulation dee
5151
## Required env variables
5252
- `ACCESS_TEAM_DOMAIN`
5353
- `ACCESS_AUD`
54-
- `REGISTRATION_MODE=approval_required`
54+
- `REGISTRATION_MODE=open`
5555
- `ADMIN_USER_IDS=<comma-separated admin ids>`
5656
- `AUTH_OBSERVABILITY=true` (recommended)
5757

5858
## Validation checklist
5959
1. In baseline mode: unauthenticated user gets Access challenge.
6060
2. In guest deep-link mode: unauthenticated user can open a shared deep link without challenge.
6161
3. In guest deep-link mode: unauthenticated user cannot access authenticated APIs (`/api/me`, `/api/library`, admin routes).
62-
4. Authenticated non-approved user lands in pending flow.
62+
4. Authenticated new user must choose a username before cloud library/sync onboarding continues.
6363
5. Admin can open:
6464
- `/api/auth-diagnostics`
6565
- `/api/schema-diagnostics`
@@ -68,4 +68,4 @@ Use this profile when anonymous users must be able to open shared Simulation dee
6868
## Common misconfigurations
6969
- Missing `ACCESS_AUD` or `ACCESS_TEAM_DOMAIN`.
7070
- `ALLOW_INSECURE_DEV_AUTH=true` in production.
71-
- Expecting Access policy alone to replace LinkSim role/approval controls.
71+
- Expecting Access policy alone to replace LinkSim resource role controls.

docs/cloudflare-auth-setup.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,17 @@ Notes:
4444
- Native email+password user database is not provided by Cloudflare Access.
4545
- Passkeys are handled by your identity provider (GitHub), not by Access itself.
4646

47-
## 4) Registration Mode (Invitation/Approval)
47+
## 4) Registration Mode
4848

4949
Set in `wrangler.toml`:
5050

51-
- `REGISTRATION_MODE = "approval_required"`
51+
- `REGISTRATION_MODE = "open"`
5252
- `ADMIN_USER_IDS = "<comma-separated user ids>"`
5353

5454
In this mode:
5555
- First login creates a user profile
56-
- User remains blocked from library/sync APIs until approved by an admin
57-
- Admins approve/revoke from User Settings UI
56+
- Users choose a username before library/sync onboarding continues
57+
- Admin IDs bootstrap admin access for the listed identities
5858

5959
## 5) Configure Pages Environment Variables
6060

@@ -63,7 +63,7 @@ In Pages project env vars (Production + Preview):
6363
- `ACCESS_TEAM_DOMAIN` = your team domain (without `https://`)
6464
- `ACCESS_AUD` = Access app AUD tag
6565
- `ADMIN_USER_IDS` = bootstrap admin user IDs
66-
- `REGISTRATION_MODE` = `approval_required`
66+
- `REGISTRATION_MODE` = `open`
6767

6868
Do not enable local dev fallback vars in production.
6969

functions/_lib/db.sharedSimulation.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const TABLE_COLUMNS: Record<string, string[]> = {
88
"id",
99
"username",
1010
"email",
11+
"username_set_at",
1112
"bio",
1213
"access_request_note",
1314
"idp_email",

functions/_lib/db.ts

Lines changed: 51 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const DB_VISIBILITIES: DbVisibility[] = ["private", "public_read", "public_write
66
const ROLES: ResourceRole[] = ["viewer", "editor", "admin"];
77

88
let schemaReady: Promise<void> | null = null;
9-
const SCHEMA_VERSION = "2026-04-07a";
9+
const SCHEMA_VERSION = "2026-05-03a";
1010
type AccountState = "pending" | "approved" | "revoked";
1111

1212
const dbVisibilityFromVisibility = (value: Visibility): DbVisibility => {
@@ -69,7 +69,7 @@ const slugifyName = (value: string): string =>
6969
.replace(/^-+|-+$/g, "")
7070
.replace(/-{2,}/g, "-");
7171

72-
const DELIMITER_CHARS = /[+<>~\/]/g;
72+
const DELIMITER_CHARS = /[+<>~/]/g;
7373
const VARIATION_SELECTORS = /[\uFE0E\uFE0F]/g;
7474

7575
export const canonicalizeSimulationLookupKey = (value: string): string =>
@@ -172,16 +172,6 @@ const sanitizeDefaultFrequencyPresetId = (value: unknown): string | null | undef
172172
return trimmed;
173173
};
174174

175-
const deriveDefaultName = (userId: string, tokenPayload?: Record<string, unknown>): string => {
176-
const fromName = sanitizeName(tokenPayload?.name);
177-
if (fromName) return fromName;
178-
const fromEmail = sanitizeEmail(tokenPayload?.email);
179-
if (fromEmail) return fromEmail.split("@")[0];
180-
const prefix = userId.includes("@") ? userId.split("@")[0] : userId;
181-
const compact = prefix.replace(/[_-]+/g, " ").trim();
182-
return sanitizeName(compact) ?? `User ${userId.slice(0, 6)}`;
183-
};
184-
185175
const deriveDefaultEmail = (userId: string, tokenPayload?: Record<string, unknown>): string => {
186176
const fromEmail = sanitizeEmail(tokenPayload?.email);
187177
if (fromEmail) return fromEmail;
@@ -216,16 +206,12 @@ const parseAdminUserIds = (env: Env): Set<string> => {
216206
);
217207
};
218208

219-
const registrationMode = (env: Env): "open" | "approval_required" => {
220-
const value = (env.REGISTRATION_MODE ?? "approval_required").trim().toLowerCase();
221-
return value === "open" ? "open" : "approval_required";
222-
};
223-
224209
const REQUIRED_COLUMNS: Record<string, string[]> = {
225210
users: [
226211
"id",
227212
"username",
228213
"email",
214+
"username_set_at",
229215
"bio",
230216
"access_request_note",
231217
"idp_email",
@@ -320,6 +306,7 @@ const ensureSchema = async (env: Env): Promise<void> => {
320306
id TEXT PRIMARY KEY,
321307
username TEXT,
322308
email TEXT,
309+
username_set_at TEXT,
323310
bio TEXT,
324311
access_request_note TEXT,
325312
idp_email TEXT,
@@ -441,6 +428,32 @@ const ensureSchema = async (env: Env): Promise<void> => {
441428
if (!userColumns.has("default_frequency_preset_id")) {
442429
await env.DB.prepare("ALTER TABLE users ADD COLUMN default_frequency_preset_id TEXT").run();
443430
}
431+
if (!userColumns.has("username_set_at")) {
432+
await env.DB.prepare("ALTER TABLE users ADD COLUMN username_set_at TEXT").run();
433+
await env.DB
434+
.prepare(
435+
`UPDATE users
436+
SET username_set_at = COALESCE(updated_at, created_at)
437+
WHERE COALESCE(TRIM(username), '') != ''`,
438+
)
439+
.run();
440+
}
441+
442+
const now = new Date().toISOString();
443+
await env.DB
444+
.prepare(
445+
`UPDATE users
446+
SET is_approved = 1,
447+
approved_at = COALESCE(approved_at, ?),
448+
approved_by_user_id = COALESCE(approved_by_user_id, 'system:open-registration'),
449+
updated_at = ?
450+
WHERE is_admin = 0
451+
AND is_moderator = 0
452+
AND is_approved = 0
453+
AND (approved_by_user_id IS NULL OR approved_by_user_id NOT LIKE 'revoked:%')`,
454+
)
455+
.bind(now, now)
456+
.run();
444457

445458
const diagnostics = await getSchemaDiagnostics(env);
446459
if (!diagnostics.ok) {
@@ -462,6 +475,7 @@ type UserRow = {
462475
id: string;
463476
username: string | null;
464477
email: string | null;
478+
username_set_at: string | null;
465479
bio: string | null;
466480
access_request_note: string | null;
467481
idp_email: string | null;
@@ -513,7 +527,8 @@ export const chooseIdentityReconcileCandidate = (
513527

514528
const toUserProfile = (row: UserRow) => ({
515529
id: row.id,
516-
username: sanitizeName(row.username) ?? "User",
530+
username: sanitizeName(row.username) ?? "",
531+
needsUsername: !row.username_set_at,
517532
email: sanitizeEmail(row.email) ?? "unknown@users.linksim.local",
518533
bio: row.bio ?? "",
519534
accessRequestNote: row.access_request_note ?? "",
@@ -554,7 +569,7 @@ const readUserRow = async (env: Env, userId: string): Promise<UserRow | null> =>
554569
await ensureSchema(env);
555570
return env.DB
556571
.prepare(
557-
"SELECT id, username, email, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users WHERE id = ?",
572+
"SELECT id, username, email, username_set_at, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users WHERE id = ?",
558573
)
559574
.bind(userId)
560575
.first<UserRow>();
@@ -570,7 +585,7 @@ const reconcileUserIdentityByIdpEmail = async (
570585

571586
const rows = await env.DB
572587
.prepare(
573-
`SELECT id, username, email, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at,
588+
`SELECT id, username, email, username_set_at, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at,
574589
CASE
575590
WHEN lower(idp_email) = lower(?) AND idp_email_verified = 1 THEN 'verified_idp_email'
576591
WHEN lower(email) = lower(?) THEN 'legacy_email'
@@ -684,61 +699,57 @@ export const ensureUser = async (
684699
await env.DB.prepare("DELETE FROM deleted_users WHERE id = ?").bind(userId).run();
685700
}
686701
const now = new Date().toISOString();
687-
const username = deriveDefaultName(userId, tokenPayload);
688702
const email = deriveDefaultEmail(userId, tokenPayload);
689703
const idpEmail = deriveVerifiedIdpEmail(tokenPayload);
690704
const idpEmailVerified = idpEmail ? 1 : 0;
691705
const isBootstrapAdmin = parseAdminUserIds(env).has(userId.toLowerCase()) ? 1 : 0;
692-
const autoApprove = isBootstrapAdmin === 1 || registrationMode(env) === "open";
706+
const autoApprove = 1;
693707

694708
await env.DB.prepare(
695709
`INSERT OR IGNORE INTO users
696-
(id, username, email, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at)
697-
VALUES (?, ?, ?, '', '', ?, ?, '', 1, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
710+
(id, username, email, username_set_at, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at)
711+
VALUES (?, '', ?, NULL, '', '', ?, ?, '', 1, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`,
698712
)
699713
.bind(
700714
userId,
701-
username,
702715
email,
703716
idpEmail || null,
704717
idpEmailVerified,
705718
isBootstrapAdmin,
706719
0,
707-
autoApprove ? 1 : 0,
708-
autoApprove ? now : null,
709-
autoApprove ? userId : null,
720+
autoApprove,
721+
now,
722+
"system:open-registration",
710723
now,
711724
now,
712725
)
713726
.run();
714727

715728
await env.DB.prepare(
716729
`UPDATE users
717-
SET username = COALESCE(NULLIF(TRIM(username), ''), ?),
718-
email = COALESCE(NULLIF(TRIM(email), ''), ?),
730+
SET email = COALESCE(NULLIF(TRIM(email), ''), ?),
719731
idp_email = CASE WHEN ? = 1 THEN COALESCE(NULLIF(TRIM(idp_email), ''), ?) ELSE idp_email END,
720732
idp_email_verified = CASE WHEN ? = 1 THEN 1 ELSE idp_email_verified END,
721733
is_admin = CASE WHEN ? = 1 THEN 1 ELSE is_admin END,
722734
is_moderator = CASE WHEN ? = 1 THEN 1 ELSE is_moderator END,
723-
is_approved = CASE WHEN ? = 1 THEN 1 ELSE is_approved END,
735+
is_approved = CASE WHEN ? = 1 AND (approved_by_user_id IS NULL OR approved_by_user_id NOT LIKE 'revoked:%') THEN 1 ELSE is_approved END,
724736
approved_at = CASE WHEN ? = 1 AND approved_at IS NULL THEN ? ELSE approved_at END,
725737
approved_by_user_id = CASE WHEN ? = 1 AND approved_by_user_id IS NULL THEN ? ELSE approved_by_user_id END,
726738
updated_at = ?
727739
WHERE id = ?`,
728740
)
729741
.bind(
730-
username,
731742
email,
732743
idpEmailVerified,
733744
idpEmail || null,
734745
idpEmailVerified,
735746
isBootstrapAdmin,
736747
0,
737-
autoApprove ? 1 : 0,
738-
autoApprove ? 1 : 0,
748+
autoApprove,
749+
autoApprove,
739750
now,
740-
autoApprove ? 1 : 0,
741-
userId,
751+
autoApprove,
752+
"system:open-registration",
742753
now,
743754
userId,
744755
)
@@ -812,6 +823,7 @@ export const updateUserProfile = async (
812823
await env.DB.prepare(
813824
`UPDATE users
814825
SET username = ?,
826+
username_set_at = CASE WHEN ? = 1 THEN COALESCE(username_set_at, ?) ELSE username_set_at END,
815827
email = ?,
816828
bio = ?,
817829
access_request_note = ?,
@@ -828,6 +840,8 @@ export const updateUserProfile = async (
828840
)
829841
.bind(
830842
nextName,
843+
patch.username === undefined ? 0 : 1,
844+
new Date().toISOString(),
831845
nextEmail,
832846
nextBio,
833847
nextAccessRequestNote,
@@ -910,7 +924,7 @@ export const listUsers = async (env: Env) => {
910924
await ensureSchema(env);
911925
const rows = await env.DB
912926
.prepare(
913-
"SELECT id, username, email, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT 2000",
927+
"SELECT id, username, email, username_set_at, bio, access_request_note, idp_email, idp_email_verified, avatar_url, email_public, default_frequency_preset_id, avatar_object_key, avatar_thumb_key, avatar_hash, avatar_bytes, avatar_content_type, is_admin, is_moderator, is_approved, approved_at, approved_by_user_id, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT 2000",
914928
)
915929
.all<UserRow>();
916930
return rows.results.map(toUserProfile);

functions/api/me.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ describe("api/me", () => {
3434
expect(res.headers.get("cache-control")).toBe("no-store");
3535
});
3636

37+
it("returns username setup state on GET", async () => {
38+
fetchUserProfileMock.mockResolvedValueOnce({ id: "u1", username: "", needsUsername: true });
39+
40+
const res = await onRequestGet(mkCtx(new Request("https://example.test/api/me")));
41+
42+
expect(res.status).toBe(200);
43+
await expect(res.json()).resolves.toEqual({ user: { id: "u1", username: "", needsUsername: true } });
44+
});
45+
3746
it("returns a controlled service unavailable response when auth verification times out", async () => {
3847
verifyAuthMock.mockRejectedValue(new Error("Auth verification timed out"));
3948
const res = await onRequestGet(mkCtx(new Request("https://example.test/api/me")));
@@ -66,4 +75,19 @@ describe("api/me", () => {
6675
expect.objectContaining({ defaultFrequencyPresetId: "mt-us" }),
6776
);
6877
});
78+
79+
it("passes username through PATCH for first username setup", async () => {
80+
const req = new Request("https://example.test/api/me", {
81+
method: "PATCH",
82+
headers: { "content-type": "application/json" },
83+
body: JSON.stringify({ username: "Ranger" }),
84+
});
85+
const res = await onRequestPatch(mkCtx(req));
86+
expect(res.status).toBe(200);
87+
expect(updateUserProfileMock).toHaveBeenCalledWith(
88+
env,
89+
"u1",
90+
expect.objectContaining({ username: "Ranger" }),
91+
);
92+
});
6993
});

0 commit comments

Comments
 (0)