+
diff --git a/src/pages/auth/callback.ts b/src/pages/auth/callback.ts
index 1acc88e5..2abf848a 100644
--- a/src/pages/auth/callback.ts
+++ b/src/pages/auth/callback.ts
@@ -3,8 +3,10 @@ import { env } from 'cloudflare:workers';
import {
exchangeCodeForToken,
fetchUserInfo,
+ isSafeRedirectPath,
OAUTH_VERIFIER_COOKIE,
OAUTH_STATE_COOKIE,
+ OAUTH_REDIRECT_COOKIE,
} from '@/lib/oauth';
import {
createSessionCookieValue,
@@ -16,8 +18,10 @@ import { upsertUser } from '@/lib/users';
export const GET: APIRoute = async ({ cookies, redirect, url }) => {
const verifier = cookies.get(OAUTH_VERIFIER_COOKIE)?.value;
const expectedState = cookies.get(OAUTH_STATE_COOKIE)?.value;
+ const redirectTo = cookies.get(OAUTH_REDIRECT_COOKIE)?.value;
cookies.delete(OAUTH_VERIFIER_COOKIE, { path: '/' });
cookies.delete(OAUTH_STATE_COOKIE, { path: '/' });
+ cookies.delete(OAUTH_REDIRECT_COOKIE, { path: '/' });
if (url.searchParams.get('error')) {
return redirect('/?auth_error=1');
@@ -77,5 +81,7 @@ export const GET: APIRoute = async ({ cookies, redirect, url }) => {
maxAge: SESSION_MAX_AGE,
});
- return redirect('/');
+ return redirect(
+ redirectTo && isSafeRedirectPath(redirectTo) ? redirectTo : '/',
+ );
};
diff --git a/src/pages/auth/login.ts b/src/pages/auth/login.ts
index bffa9d97..8eb9a833 100644
--- a/src/pages/auth/login.ts
+++ b/src/pages/auth/login.ts
@@ -7,8 +7,10 @@ import {
} from '@/lib/pkce';
import {
buildAuthorizeUrl,
+ isSafeRedirectPath,
OAUTH_VERIFIER_COOKIE,
OAUTH_STATE_COOKIE,
+ OAUTH_REDIRECT_COOKIE,
OAUTH_COOKIE_MAX_AGE,
} from '@/lib/oauth';
@@ -33,6 +35,17 @@ export const GET: APIRoute = async ({ cookies, redirect, url }) => {
maxAge: OAUTH_COOKIE_MAX_AGE,
});
+ const redirectTo = url.searchParams.get('redirect');
+ if (redirectTo && isSafeRedirectPath(redirectTo)) {
+ cookies.set(OAUTH_REDIRECT_COOKIE, redirectTo, {
+ httpOnly: true,
+ secure,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: OAUTH_COOKIE_MAX_AGE,
+ });
+ }
+
const redirectUri = `${url.origin}/auth/callback`;
const authorizeUrl = buildAuthorizeUrl({
clientId: env.AUTH_CLIENT_ID,
diff --git a/src/types/index.ts b/src/types/index.ts
index 666886b8..45538816 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,15 +1,22 @@
import { gt, lt } from 'semver';
+export const EXTENSION_TYPES = [
+ 'mod',
+ 'theme',
+ 'payment-gateway',
+ 'server-manager',
+ 'domain-registrar',
+ 'hook',
+ 'translation',
+] as const;
+
+export const SOURCE_TYPES = ['github', 'gitlab', 'custom'] as const;
+
+export const AUTHOR_TYPES = ['user', 'organization'] as const;
+
export type Extension = {
id: string;
- type:
- | 'mod'
- | 'theme'
- | 'payment-gateway'
- | 'server-manager'
- | 'domain-registrar'
- | 'hook'
- | 'translation';
+ type: (typeof EXTENSION_TYPES)[number];
name: string;
description: string;
author: Author;
@@ -27,7 +34,7 @@ export type Extension = {
};
export type Repository = {
- type: 'github' | 'gitlab' | 'custom';
+ type: (typeof SOURCE_TYPES)[number];
repo: string;
};
@@ -84,6 +91,31 @@ export function sortReleasesDescending(releases: Release[]): Release[] {
});
}
+// Shape sent to/from the api repo's v2 submissions endpoints
+// (src/services/extensions/v2/interfaces.ts there). ExtensionPayload omits the
+// joined `author` field of Extension — the author is submitted separately.
+export type ExtensionPayload = Omit;
+
+export type SubmissionPayload = {
+ author: Author;
+ extension: ExtensionPayload;
+};
+
+export type SubmissionStatus = 'pending' | 'approved' | 'rejected';
+
+export type Submission = {
+ id: string;
+ extension_id: string | null;
+ author_id: string;
+ submitted_by: string;
+ status: SubmissionStatus;
+ payload: SubmissionPayload;
+ reviewer_id: string | null;
+ review_note: string | null;
+ created_at: string;
+ reviewed_at: string | null;
+};
+
export function repositoryURL(repository: Repository): string {
switch (repository.type) {
case 'github':
diff --git a/wrangler.jsonc b/wrangler.jsonc
index af30429b..bb93722f 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -16,6 +16,9 @@
"workers_dev": false,
"preview_urls": true,
"keep_vars": true,
+ "vars": {
+ "EXTENSIONS_API_BASE_URL": "https://api.fossbilling.net"
+ },
"routes": [
{
"pattern": "extensions.fossbilling.org",
From f63edb339152d2ca2ebdee092dc1012f963d37a2 Mon Sep 17 00:00:00 2001
From: Adam Daley
Date: Fri, 24 Jul 2026 14:19:07 +0100
Subject: [PATCH 06/14] Add developer and account profile workflows
---
README.md | 45 ++++--
src/components/ExtensionSubmissionForm.astro | 52 +------
src/lib/apiClient.ts | 23 ++-
src/lib/author-form.ts | 19 +++
src/lib/database.ts | 16 ++-
.../db/migrations/0002_add_profile_fields.sql | 8 ++
src/lib/db/users.sql | 8 ++
src/lib/submission-form.ts | 25 ++--
src/lib/users.ts | 30 ++++
src/pages/account/developer/index.astro | 133 ++++++++++++++++++
src/pages/account/extensions/new.astro | 3 +
src/pages/account/index.astro | 74 ++++++++--
.../moderate/developers/[id]/approve.ts | 26 ++++
.../account/moderate/developers/index.astro | 90 ++++++++++++
src/pages/account/moderate/index.astro | 7 +-
src/pages/account/profile/index.astro | 69 +++++++++
src/types/index.ts | 6 +
17 files changed, 547 insertions(+), 87 deletions(-)
create mode 100644 src/lib/author-form.ts
create mode 100644 src/lib/db/migrations/0002_add_profile_fields.sql
create mode 100644 src/pages/account/developer/index.astro
create mode 100644 src/pages/account/moderate/developers/[id]/approve.ts
create mode 100644 src/pages/account/moderate/developers/index.astro
create mode 100644 src/pages/account/profile/index.astro
diff --git a/README.md b/README.md
index 15cd08e1..a8241001 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,9 @@ Extensions can also be installed manually by downloading an archive, extracting
## Submitting Extensions
-Sign in (top-right of the site) and visit [/account](https://extensions.fossbilling.org/account) to submit a new extension or edit one you already publish. Submissions and edits are reviewed by a moderator before they appear in (or change) the public directory — see [Authentication](#authentication) below for how ownership and moderation work.
+Sign in (top-right of the site) and visit [/account](https://extensions.fossbilling.org/account). First-time publishers create a [developer profile](https://extensions.fossbilling.org/account/developer) (publisher name, type, URL) — this takes effect immediately, no approval needed, so you can submit new extensions or edit ones you already publish right away. A moderator can separately mark a profile as approved, shown as a badge; extension submissions themselves still go through moderator review before they appear in (or change) the public directory — see [Authentication](#authentication) below for how ownership and moderation work.
+
+Your personal [account profile](https://extensions.fossbilling.org/account/profile) (display name, bio) is separate from your developer profile — it's not shown publicly yet, but is there ahead of future features like comments and ratings.
## Badges
@@ -80,11 +82,13 @@ Apply the `users` table to your local D1 database:
npm run db:migrate:local
```
-If you already had a `users` table from before the `is_moderator` column existed, also
-run the one-time migration (a fresh table created just above already has it):
+If you already had a `users` table from before the `is_moderator` or `display_name`/`bio`
+columns existed, also run the one-time migrations (a fresh table created just above already
+has them):
```bash
npx wrangler d1 execute DB_EXTENSIONS --local --file=./src/lib/db/migrations/0001_add_is_moderator.sql
+npx wrangler d1 execute DB_EXTENSIONS --local --file=./src/lib/db/migrations/0002_add_profile_fields.sql
```
Start the development server:
@@ -111,14 +115,32 @@ that exchange.
### Extension submission, ownership, and moderation
-Signed-in users can submit and manage extensions from `/account`. All writes to the
-shared `authors`/`extensions` tables happen in the
-[`FOSSBilling/api`](https://github.com/FOSSBilling/api) repo's `/extensions/v2`
-service, not here — this app never writes to those tables directly. New submissions
-and edits go into a moderation queue there and only take effect once a moderator
-approves them; this app's own `getExtensionsByOwner`/`getExtensionById` (in
-`src/lib/database.ts`) still read the live tables directly, same as the public
-listings.
+Signed-in users manage two separate profiles from `/account`:
+
+- **Account profile** (`/account/profile`) — personal `display_name`/`bio`, stored only in
+ this app's own `users` table. Written directly to D1 here, not moderated (not yet shown
+ publicly).
+- **Developer profile** (`/account/developer`) — the publisher identity (`authors` row:
+ name, type, URL) shown on your extensions in the directory. Writes take effect
+ immediately (`PUT /extensions/v2/authors/me`) — there's no moderation gate on creating or
+ editing one. A moderator can mark a profile **approved** as a trust badge
+ (`/account/moderate/developers`); it's cosmetic, not a publish gate, and any edit clears
+ the badge again until it's re-reviewed.
+
+An extension submission always targets an existing, owned developer profile — the two are
+deliberately kept separate (rather than letting extension submission implicitly create/edit
+an author) so that things like reassigning an extension to a different author, or transferring
+a developer profile to another account, stay simple additions later instead of needing to be
+untangled from the submission form.
+
+Extension submissions (new extensions and edits) are the one thing still moderated: they go
+into a queue at `/account/moderate` and only take effect once a moderator approves them — a
+higher bar than developer profiles since they carry download URLs and arbitrary readme/website
+content. All writes to the shared `authors`/`extensions` tables — moderated or not — happen in
+the [`FOSSBilling/api`](https://github.com/FOSSBilling/api) repo's `/extensions/v2` service,
+not here; this app never writes to those tables directly. This app's own
+`getExtensionsByOwner`/`getExtensionById`/`getAuthorByOwner` (in `src/lib/database.ts`) read
+the live tables directly, same as the public listings.
Each request to `/extensions/v2` is authenticated with a short-lived (60s) HMAC-signed
bearer assertion this app mints per-request (`src/lib/assertion.ts`), proving the
@@ -144,6 +166,7 @@ npx wrangler secret put ASSERTION_SIGNING_SECRET
```bash
npm run db:migrate:remote
npx wrangler d1 execute DB_EXTENSIONS --remote --file=./src/lib/db/migrations/0001_add_is_moderator.sql
+npx wrangler d1 execute DB_EXTENSIONS --remote --file=./src/lib/db/migrations/0002_add_profile_fields.sql
```
## License
diff --git a/src/components/ExtensionSubmissionForm.astro b/src/components/ExtensionSubmissionForm.astro
index bff7736a..f2336b85 100644
--- a/src/components/ExtensionSubmissionForm.astro
+++ b/src/components/ExtensionSubmissionForm.astro
@@ -1,9 +1,9 @@
---
-import { EXTENSION_TYPES, SOURCE_TYPES, AUTHOR_TYPES } from '@/types';
+import { EXTENSION_TYPES, SOURCE_TYPES } from '@/types';
import type { Author, Extension } from '@/types';
interface Props {
- author: Author | null;
+ author: Author;
extension?: Extension;
error?: string | null;
}
@@ -23,50 +23,10 @@ const isEdit = Boolean(extension);
+ These profiles are already live — approval is a trust badge, not a publish
+ gate. An edit to an approved profile clears its badge again until it's
+ reviewed here.
+
+ Your personal profile, separate from any developer/publisher identity. Not
+ shown publicly yet — it's here for things like comments and ratings down
+ the line.
+
+
+
+
+
diff --git a/src/types/index.ts b/src/types/index.ts
index 45538816..6f4906ba 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -101,6 +101,12 @@ export type SubmissionPayload = {
extension: ExtensionPayload;
};
+// The `authors` row plus a moderator-set trust flag — see the api repo's
+// src/services/extensions/v2/interfaces.ts (AuthorProfileSchema). Developer
+// profiles are written directly (PUT /extensions/v2/authors/me), not through
+// the submission/moderation queue; `approved` is purely a badge, not a gate.
+export type AuthorProfile = Author & { approved: boolean };
+
export type SubmissionStatus = 'pending' | 'approved' | 'rejected';
export type Submission = {
From 5aa3d918d9944f5d394e0b998be7195bfd936183 Mon Sep 17 00:00:00 2001
From: Adam Daley
Date: Fri, 24 Jul 2026 14:42:20 +0100
Subject: [PATCH 07/14] Add public developer pages and profile fields
---
README.md | 12 ++--
src/components/DetailsCard.astro | 2 +-
src/lib/apiClient.ts | 4 +-
src/lib/author-form.ts | 15 ++--
src/lib/database.ts | 93 +++++++++++++++++++-----
src/pages/account/developer/index.astro | 95 +++++++++++++++++++++----
src/pages/developer/[id].astro | 75 +++++++++++++++++++
src/types/index.ts | 14 +++-
8 files changed, 268 insertions(+), 42 deletions(-)
create mode 100644 src/pages/developer/[id].astro
diff --git a/README.md b/README.md
index a8241001..250ebc7d 100644
--- a/README.md
+++ b/README.md
@@ -121,11 +121,14 @@ Signed-in users manage two separate profiles from `/account`:
this app's own `users` table. Written directly to D1 here, not moderated (not yet shown
publicly).
- **Developer profile** (`/account/developer`) — the publisher identity (`authors` row:
- name, type, URL) shown on your extensions in the directory. Writes take effect
+ name, type, URL, bio, avatar, and a private contact email) shown on your extensions in the
+ directory and on your public developer page at `/developer/[id]`. Writes take effect
immediately (`PUT /extensions/v2/authors/me`) — there's no moderation gate on creating or
editing one. A moderator can mark a profile **approved** as a trust badge
(`/account/moderate/developers`); it's cosmetic, not a publish gate, and any edit clears
- the badge again until it's re-reviewed.
+ the badge again until it's re-reviewed. `contact_email` is never read by any public-facing
+ query (`getAuthorById` in `src/lib/database.ts` deliberately omits it) — only
+ `getAuthorByOwner`, used for the owner's own self-management form, selects it.
An extension submission always targets an existing, owned developer profile — the two are
deliberately kept separate (rather than letting extension submission implicitly create/edit
@@ -139,8 +142,9 @@ higher bar than developer profiles since they carry download URLs and arbitrary
content. All writes to the shared `authors`/`extensions` tables — moderated or not — happen in
the [`FOSSBilling/api`](https://github.com/FOSSBilling/api) repo's `/extensions/v2` service,
not here; this app never writes to those tables directly. This app's own
-`getExtensionsByOwner`/`getExtensionById`/`getAuthorByOwner` (in `src/lib/database.ts`) read
-the live tables directly, same as the public listings.
+`getExtensionsByOwner`/`getExtensionById`/`getAuthorByOwner`/`getAuthorById`/
+`getExtensionsByAuthorId` (in `src/lib/database.ts`) read the live tables directly, same as
+the public listings — including the public developer page at `/developer/[id]`.
Each request to `/extensions/v2` is authenticated with a short-lived (60s) HMAC-signed
bearer assertion this app mints per-request (`src/lib/assertion.ts`), proving the
diff --git a/src/components/DetailsCard.astro b/src/components/DetailsCard.astro
index 6549110d..9358777b 100644
--- a/src/components/DetailsCard.astro
+++ b/src/components/DetailsCard.astro
@@ -41,7 +41,7 @@ const details: DetailsItem[] = [
icon: User,
color: 'amber',
text: ext.author.id,
- link: ext.website,
+ link: `/developer/${ext.author.id}`,
},
{
name: 'FOSSBilling Version',
diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts
index 45d1056d..eb4ebefd 100644
--- a/src/lib/apiClient.ts
+++ b/src/lib/apiClient.ts
@@ -3,8 +3,8 @@
// minting a bearer assertion requires ASSERTION_SIGNING_SECRET.
import { mintBearerAssertion } from './assertion';
import type {
- Author,
AuthorProfile,
+ AuthorProfileInput,
Submission,
SubmissionPayload,
SubmissionStatus,
@@ -73,7 +73,7 @@ export function createApiClient(env: Cloudflare.Env, sub: string) {
// Direct write, not moderated — takes effect immediately. `approved` in
// the response is a moderator-set trust badge, not a publish gate.
- upsertAuthorProfile: (author: Author) =>
+ upsertAuthorProfile: (author: AuthorProfileInput) =>
call('/authors/me', {
method: 'PUT',
body: JSON.stringify(author),
diff --git a/src/lib/author-form.ts b/src/lib/author-form.ts
index f98b068b..42d663d5 100644
--- a/src/lib/author-form.ts
+++ b/src/lib/author-form.ts
@@ -1,12 +1,12 @@
-import type { Author } from '@/types';
+import type { Author, AuthorProfileInput } from '@/types';
-// Builds an Author from the developer-profile form, for PUT /authors/me. The
-// id is immutable once an author exists — see the readonly id field in
-// /account/developer.
+// Builds an AuthorProfileInput from the developer-profile form, for
+// PUT /authors/me. The id is immutable once an author exists — see the
+// readonly id field in /account/developer.
export function buildAuthorProfile(
form: FormData,
existingAuthor: Author | null,
-): Author {
+): AuthorProfileInput {
const str = (name: string) =>
((form.get(name) as string | null) ?? '').trim();
@@ -15,5 +15,8 @@ export function buildAuthorProfile(
type: (str('type') || 'user') as Author['type'],
name: str('name'),
URL: str('url') || undefined,
- } as Author;
+ bio: str('bio') || undefined,
+ avatar_url: str('avatar_url') || undefined,
+ contact_email: str('contact_email') || undefined,
+ } as AuthorProfileInput;
}
diff --git a/src/lib/database.ts b/src/lib/database.ts
index e173e754..132e5b86 100644
--- a/src/lib/database.ts
+++ b/src/lib/database.ts
@@ -24,6 +24,43 @@ const SELECT_EXTENSIONS_BY_OWNER = `
ORDER BY e.name
`;
+const SELECT_EXTENSIONS_BY_AUTHOR = `
+ ${SELECT_EXTENSIONS_LIST}
+ WHERE LOWER(a.id) = LOWER(?)
+ ORDER BY e.name
+`;
+
+// contact_email is deliberately never selected here — this repo has no
+// public-facing query that should return it. Only getAuthorByOwner (below)
+// selects it, for prefilling the owner's own self-management form.
+const SELECT_AUTHOR_PUBLIC = `
+ SELECT id, type, name, url, bio, avatar_url, approved_at FROM authors
+`;
+
+type AuthorProfileRow = {
+ id: string;
+ type: string;
+ name: string;
+ url: string | null;
+ bio?: string | null;
+ avatar_url: string | null;
+ contact_email?: string | null;
+ approved_at: string | null;
+};
+
+function parseAuthorProfileRow(row: AuthorProfileRow): AuthorProfile {
+ return {
+ type: row.type as 'organization' | 'user',
+ name: row.name,
+ id: row.id.toLowerCase() as Lowercase,
+ URL: row.url ?? undefined,
+ bio: row.bio ?? undefined,
+ avatar_url: row.avatar_url ?? undefined,
+ contact_email: row.contact_email ?? undefined,
+ approved: row.approved_at !== null,
+ } as AuthorProfile;
+}
+
// Includes readme — used for detail pages.
const SELECT_EXTENSION_DETAIL = `
SELECT e.id, e.type, e.author_id,
@@ -83,6 +120,8 @@ export async function getExtensionsByOwner(
return result.results.map(parseExtensionRow);
}
+// Includes contact_email — this is the owner viewing/editing their own
+// profile, not a public read.
export async function getAuthorByOwner(
db: D1Database,
userId: string,
@@ -91,27 +130,49 @@ export async function getAuthorByOwner(
try {
row = await db
.prepare(
- 'SELECT id, type, name, url, approved_at FROM authors WHERE owner_user_id = ?',
+ 'SELECT id, type, name, url, bio, avatar_url, contact_email, approved_at FROM authors WHERE owner_user_id = ?',
)
.bind(userId)
- .first<{
- id: string;
- type: string;
- name: string;
- url: string | null;
- approved_at: string | null;
- }>();
+ .first();
} catch {
return null;
}
- if (!row) return null;
- return {
- type: row.type as 'organization' | 'user',
- name: row.name,
- id: row.id.toLowerCase() as Lowercase,
- URL: row.url ?? undefined,
- approved: row.approved_at !== null,
- } as AuthorProfile;
+ return row ? parseAuthorProfileRow(row) : null;
+}
+
+// Public read for the /developer/[id] page — never selects contact_email.
+export async function getAuthorById(
+ db: D1Database,
+ id: string,
+): Promise {
+ let row;
+ try {
+ row = await db
+ .prepare(`${SELECT_AUTHOR_PUBLIC} WHERE LOWER(id) = LOWER(?)`)
+ .bind(id)
+ .first();
+ } catch {
+ return null;
+ }
+ return row ? parseAuthorProfileRow(row) : null;
+}
+
+// Public listing for the /developer/[id] page.
+export async function getExtensionsByAuthorId(
+ db: D1Database,
+ authorId: string,
+): Promise {
+ let result;
+ try {
+ result = await db
+ .prepare(SELECT_EXTENSIONS_BY_AUTHOR)
+ .bind(authorId)
+ .all>();
+ } catch {
+ return [];
+ }
+ if (!result.success) return [];
+ return result.results.map(parseExtensionRow);
}
function parseJSON(value: unknown, fallback: T): T {
diff --git a/src/pages/account/developer/index.astro b/src/pages/account/developer/index.astro
index 684c506d..ffcdff4c 100644
--- a/src/pages/account/developer/index.astro
+++ b/src/pages/account/developer/index.astro
@@ -22,7 +22,7 @@ if (Astro.request.method === 'POST') {
const api = createApiClient(env, user.sub);
try {
- await api.upsertAuthorProfile(payload);
+ author = await api.upsertAuthorProfile(payload);
return Astro.redirect('/account?profile_saved=1');
} catch (e) {
error =
@@ -37,22 +37,44 @@ if (Astro.request.method === 'POST') {
Developer profile
- This is the publisher identity shown on your extensions in the directory.
- Changes take effect immediately — you can submit extensions under this
- profile right away. A moderator will review it separately and mark it
- approved, shown as a badge below.
+ This is the publisher identity shown on your extensions in the directory,
+ and on your public developer page. Changes take effect immediately — you
+ can submit extensions under this profile right away. A moderator will
+ review it separately and mark it approved, shown as a badge below.
Never shown publicly — only visible to FOSSBilling moderators, in
diff --git a/src/pages/account/extensions/[id]/edit.astro b/src/pages/account/extensions/[id]/edit.astro
index 3f88e018..2e6bb788 100644
--- a/src/pages/account/extensions/[id]/edit.astro
+++ b/src/pages/account/extensions/[id]/edit.astro
@@ -5,7 +5,10 @@ import { env } from 'cloudflare:workers';
import { requireUser } from '@/lib/auth-guard';
import { createApiClient, ApiRequestError } from '@/lib/apiClient';
import { getDeveloperByOwner, getExtensionById } from '@/lib/database';
-import { buildSubmissionPayload } from '@/lib/submission-form';
+import {
+ buildSubmissionPayload,
+ SubmissionValidationError,
+} from '@/lib/submission-form';
const guard = await requireUser(Astro, env);
if (guard instanceof Response) return guard;
@@ -30,15 +33,14 @@ let error: string | null = null;
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
- const payload = buildSubmissionPayload(form, developer, extension);
-
const api = createApiClient(env, user.sub);
try {
+ const payload = buildSubmissionPayload(form, developer, extension);
await api.submitExtension(payload);
return Astro.redirect('/account?submitted=1');
} catch (e) {
error =
- e instanceof ApiRequestError
+ e instanceof ApiRequestError || e instanceof SubmissionValidationError
? e.message
: 'Something went wrong submitting your edit. Please try again.';
}
diff --git a/src/pages/account/extensions/new.astro b/src/pages/account/extensions/new.astro
index b2096121..912bd5a4 100644
--- a/src/pages/account/extensions/new.astro
+++ b/src/pages/account/extensions/new.astro
@@ -5,7 +5,10 @@ import { env } from 'cloudflare:workers';
import { requireUser } from '@/lib/auth-guard';
import { createApiClient, ApiRequestError } from '@/lib/apiClient';
import { getDeveloperByOwner } from '@/lib/database';
-import { buildSubmissionPayload } from '@/lib/submission-form';
+import {
+ buildSubmissionPayload,
+ SubmissionValidationError,
+} from '@/lib/submission-form';
const guard = await requireUser(Astro, env);
if (guard instanceof Response) return guard;
@@ -20,15 +23,14 @@ let error: string | null = null;
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
- const payload = buildSubmissionPayload(form, developer);
-
const api = createApiClient(env, user.sub);
try {
+ const payload = buildSubmissionPayload(form, developer);
await api.submitExtension(payload);
return Astro.redirect('/account?submitted=1');
} catch (e) {
error =
- e instanceof ApiRequestError
+ e instanceof ApiRequestError || e instanceof SubmissionValidationError
? e.message
: 'Something went wrong submitting your extension. Please try again.';
}
diff --git a/src/pages/account/moderate/[id]/reject.ts b/src/pages/account/moderate/[id]/reject.ts
index 9cdd41f4..f62b096f 100644
--- a/src/pages/account/moderate/[id]/reject.ts
+++ b/src/pages/account/moderate/[id]/reject.ts
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { env } from 'cloudflare:workers';
import { requireModerator } from '@/lib/auth-guard';
import { createApiClient, ApiRequestError } from '@/lib/apiClient';
+import { formString } from '@/lib/form';
export const POST: APIRoute = async (context) => {
const guard = await requireModerator(context, env);
@@ -19,7 +20,7 @@ export const POST: APIRoute = async (context) => {
`/account/moderate?error=${encodeURIComponent('Malformed request.')}`,
);
}
- const reviewNote = ((form.get('review_note') as string | null) ?? '').trim();
+ const reviewNote = formString(form, 'review_note');
if (!reviewNote) {
return context.redirect(
`/account/moderate?error=${encodeURIComponent('A reason is required to reject a submission.')}`,
diff --git a/src/pages/account/moderate/developers/[id]/history.astro b/src/pages/account/moderate/developers/[id]/history.astro
index de120a8d..18dadf35 100644
--- a/src/pages/account/moderate/developers/[id]/history.astro
+++ b/src/pages/account/moderate/developers/[id]/history.astro
@@ -3,6 +3,7 @@ import Base from '../../../../../layouts/Base.astro';
import { env } from 'cloudflare:workers';
import { requireModerator } from '@/lib/auth-guard';
import { createApiClient, ApiRequestError } from '@/lib/apiClient';
+import { isSafeHttpUrl } from '@/lib/safe-url';
import type { DeveloperHistoryEntry } from '@/types';
const guard = await requireModerator(Astro, env);
@@ -73,7 +74,12 @@ try {
{entry.URL && (
<>
{' '}
- · {entry.URL}
+ ·{' '}
+ {isSafeHttpUrl(entry.URL) ? (
+ {entry.URL}
+ ) : (
+ entry.URL
+ )}
>
)}
diff --git a/src/pages/account/moderate/developers/claims/[id]/reject.ts b/src/pages/account/moderate/developers/claims/[id]/reject.ts
index 7260514f..14409638 100644
--- a/src/pages/account/moderate/developers/claims/[id]/reject.ts
+++ b/src/pages/account/moderate/developers/claims/[id]/reject.ts
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { env } from 'cloudflare:workers';
import { requireModerator } from '@/lib/auth-guard';
import { createApiClient, ApiRequestError } from '@/lib/apiClient';
+import { formString } from '@/lib/form';
export const POST: APIRoute = async (context) => {
const guard = await requireModerator(context, env);
@@ -19,7 +20,7 @@ export const POST: APIRoute = async (context) => {
`/account/moderate/developers/claims?error=${encodeURIComponent('Malformed request.')}`,
);
}
- const reviewNote = ((form.get('review_note') as string | null) ?? '').trim();
+ const reviewNote = formString(form, 'review_note');
if (!reviewNote) {
return context.redirect(
`/account/moderate/developers/claims?error=${encodeURIComponent('A reason is required to reject a claim.')}`,
diff --git a/src/pages/account/moderate/developers/index.astro b/src/pages/account/moderate/developers/index.astro
index 250a41f7..20728d29 100644
--- a/src/pages/account/moderate/developers/index.astro
+++ b/src/pages/account/moderate/developers/index.astro
@@ -3,6 +3,7 @@ import Base from '../../../../layouts/Base.astro';
import { env } from 'cloudflare:workers';
import { requireModerator } from '@/lib/auth-guard';
import { createApiClient } from '@/lib/apiClient';
+import { isSafeHttpUrl } from '@/lib/safe-url';
import type { DeveloperProfile } from '@/types';
const guard = await requireModerator(Astro, env);
@@ -104,7 +105,12 @@ try {
{d.URL && (
<>
{' '}
- · {d.URL}
+ ·{' '}
+ {isSafeHttpUrl(d.URL) ? (
+ {d.URL}
+ ) : (
+ d.URL
+ )}
>
)}
diff --git a/src/pages/account/profile/index.astro b/src/pages/account/profile/index.astro
index 743bb46d..6fb5db7c 100644
--- a/src/pages/account/profile/index.astro
+++ b/src/pages/account/profile/index.astro
@@ -3,6 +3,7 @@ import Base from '../../../layouts/Base.astro';
import { env } from 'cloudflare:workers';
import { requireUser } from '@/lib/auth-guard';
import { getUserProfile, updateUserProfile } from '@/lib/users';
+import { formString } from '@/lib/form';
const guard = await requireUser(Astro, env);
if (guard instanceof Response) return guard;
@@ -10,10 +11,8 @@ const user = guard;
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
- const displayName = (
- (form.get('display_name') as string | null) ?? ''
- ).trim();
- const bio = ((form.get('bio') as string | null) ?? '').trim();
+ const displayName = formString(form, 'display_name');
+ const bio = formString(form, 'bio');
await updateUserProfile(env.DB_EXTENSIONS, user.sub, {
display_name: displayName || null,
diff --git a/src/pages/auth/login.ts b/src/pages/auth/login.ts
index 8eb9a833..db3c25f1 100644
--- a/src/pages/auth/login.ts
+++ b/src/pages/auth/login.ts
@@ -44,6 +44,10 @@ export const GET: APIRoute = async ({ cookies, redirect, url }) => {
path: '/',
maxAge: OAUTH_COOKIE_MAX_AGE,
});
+ } else {
+ // Otherwise a stale cookie from an earlier, abandoned login attempt
+ // would linger and redirect this attempt somewhere it didn't ask for.
+ cookies.delete(OAUTH_REDIRECT_COOKIE, { path: '/' });
}
const redirectUri = `${url.origin}/auth/callback`;
diff --git a/src/pages/auth/logout.ts b/src/pages/auth/logout.ts
index d94b6265..7e6abdad 100644
--- a/src/pages/auth/logout.ts
+++ b/src/pages/auth/logout.ts
@@ -1,7 +1,18 @@
import type { APIRoute } from 'astro';
import { SESSION_COOKIE } from '@/lib/session';
-export const POST: APIRoute = async ({ cookies, redirect }) => {
+// A cross-site page could auto-submit this form to force a visitor's
+// session to be cleared. SameSite=Lax on the session cookie already stops a
+// cross-site POST from carrying it, but the response's Set-Cookie deletion
+// would still apply regardless — so check Origin ourselves rather than rely
+// on that. Same-origin requests always send Origin on POST; only reject
+// when it's present and doesn't match.
+export const POST: APIRoute = async ({ cookies, redirect, request, url }) => {
+ const origin = request.headers.get('Origin');
+ if (origin && origin !== url.origin) {
+ return redirect('/');
+ }
+
cookies.delete(SESSION_COOKIE, { path: '/' });
return redirect('/');
};
diff --git a/src/pages/developer/[id].astro b/src/pages/developer/[id].astro
index 26d22113..b6bafaeb 100644
--- a/src/pages/developer/[id].astro
+++ b/src/pages/developer/[id].astro
@@ -5,6 +5,8 @@ import { getDeveloperById, getExtensionsByDeveloperId } from '@/lib/database';
import { getSessionUser } from '@/lib/session';
import { requireUser } from '@/lib/auth-guard';
import { createApiClient, ApiRequestError } from '@/lib/apiClient';
+import { formString } from '@/lib/form';
+import { isSafeHttpUrl } from '@/lib/safe-url';
import { env } from 'cloudflare:workers';
const { id } = Astro.params;
@@ -30,7 +32,7 @@ if (Astro.request.method === 'POST' && developer.unclaimed) {
const user = guard;
const form = await Astro.request.formData();
- const note = ((form.get('note') as string | null) ?? '').trim();
+ const note = formString(form, 'note');
const api = createApiClient(env, user.sub);
try {
@@ -74,7 +76,7 @@ const extensions = await getExtensionsByDeveloperId(
)
}
{
- developer.URL && (
+ developer.URL && isSafeHttpUrl(developer.URL) ? (
{developer.URL}
+ ) : (
+ developer.URL && (
+
{developer.URL}
+ )
)
}
diff --git a/src/types/index.ts b/src/types/index.ts
index 06aa6280..b8dcd534 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -124,7 +124,10 @@ export type DeveloperProfile = Developer & {
// Body for PUT /extensions/v2/developers/me — everything but the server-set
// `approved` flag.
-export type DeveloperProfileInput = Omit;
+export type DeveloperProfileInput = Omit<
+ DeveloperProfile,
+ 'approved' | 'unclaimed'
+>;
// A snapshot of a developers row as it existed right after one
// PUT /developers/me write — see the api repo's DeveloperHistoryEntrySchema.
diff --git a/wrangler.jsonc b/wrangler.jsonc
index bb93722f..24d8c34c 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -33,7 +33,8 @@
"d1_databases": [
{
"binding": "DB_EXTENSIONS",
- "database_id": "b99e8d34-4856-4f92-837d-5413869d3d83"
+ "database_id": "b99e8d34-4856-4f92-837d-5413869d3d83",
+ "migrations_dir": "./src/lib/db/migrations"
}
]
}
\ No newline at end of file
From 5f603e12f1d32c66bc255edc52fe659c8b2f15b7 Mon Sep 17 00:00:00 2001
From: Adam Daley
Date: Sun, 26 Jul 2026 21:05:33 +0100
Subject: [PATCH 13/14] Add developer profile and account deletion
Lets a developer delete just their publisher profile or their whole
account, gated on having no published extensions to avoid orphaning
them. Requires the api repo's new DELETE /developers/me endpoint.
Claude-Session: https://claude.ai/code/session_01GP7BWksJf2KBFc2RGirEyN
---
src/layouts/Base.astro | 7 ++
src/lib/apiClient.ts | 7 ++
src/lib/users.ts | 7 ++
src/pages/account/delete.astro | 96 +++++++++++++++++++++++++
src/pages/account/developer/index.astro | 59 ++++++++++++++-
src/pages/account/index.astro | 16 ++++-
6 files changed, 188 insertions(+), 4 deletions(-)
create mode 100644 src/pages/account/delete.astro
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index 4e8cf331..3662c6ac 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -148,6 +148,13 @@ const user = await getSessionUser(Astro.cookies, env.SESSION_SECRET);
)
}
+ {
+ Astro.url.searchParams.get('account_deleted') && (
+
+ Your account has been deleted.
+
+ )
+ }
diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts
index 4d34f3d2..a90acc48 100644
--- a/src/lib/apiClient.ts
+++ b/src/lib/apiClient.ts
@@ -83,6 +83,13 @@ export function createApiClient(env: Cloudflare.Env, sub: string) {
body: JSON.stringify(developer),
}),
+ // 409s (developer_has_extensions / developer_has_pending_submissions) if
+ // the profile can't be deleted yet — callers should surface the message.
+ deleteDeveloperProfile: () =>
+ call<{ id: string; deleted: true }>('/developers/me', {
+ method: 'DELETE',
+ }),
+
listUnapprovedDevelopers: () =>
call('/developers/unapproved'),
diff --git a/src/lib/users.ts b/src/lib/users.ts
index 97dfde69..42aa431d 100644
--- a/src/lib/users.ts
+++ b/src/lib/users.ts
@@ -39,6 +39,13 @@ export async function isModerator(
return row?.is_moderator === 1;
}
+export async function deleteUser(
+ db: D1Database,
+ userId: string,
+): Promise {
+ await db.prepare('DELETE FROM users WHERE id = ?').bind(userId).run();
+}
+
export type UserProfile = {
display_name: string | null;
bio: string | null;
diff --git a/src/pages/account/delete.astro b/src/pages/account/delete.astro
new file mode 100644
index 00000000..df7aed63
--- /dev/null
+++ b/src/pages/account/delete.astro
@@ -0,0 +1,96 @@
+---
+import Base from '../../layouts/Base.astro';
+import { env } from 'cloudflare:workers';
+import { requireUser } from '@/lib/auth-guard';
+import { createApiClient, ApiRequestError } from '@/lib/apiClient';
+import { getDeveloperByOwner, getExtensionsByOwner } from '@/lib/database';
+import { deleteUser } from '@/lib/users';
+import { SESSION_COOKIE } from '@/lib/session';
+
+const guard = await requireUser(Astro, env);
+if (guard instanceof Response) return guard;
+const user = guard;
+
+const developer = await getDeveloperByOwner(env.DB_EXTENSIONS, user.sub);
+const extensions = developer
+ ? await getExtensionsByOwner(env.DB_EXTENSIONS, user.sub)
+ : [];
+
+let error: string | null = null;
+
+// extensions.length > 0 falls through here as a no-op — the message below
+// already explains why, so no separate error banner is needed.
+if (Astro.request.method === 'POST' && extensions.length === 0) {
+ if (developer) {
+ const api = createApiClient(env, user.sub);
+ try {
+ await api.deleteDeveloperProfile();
+ } catch (e) {
+ error =
+ e instanceof ApiRequestError
+ ? e.message
+ : 'Unable to delete your developer profile. Please try again.';
+ }
+ }
+
+ if (!error) {
+ await deleteUser(env.DB_EXTENSIONS, user.sub);
+ Astro.cookies.delete(SESSION_COOKIE, { path: '/' });
+ return Astro.redirect('/?account_deleted=1');
+ }
+}
+---
+
+
+
+
+ Delete account
+
+
+ {
+ error && (
+
+ {error}
+
+ )
+ }
+
+ {
+ extensions.length > 0 ? (
+
+ You have {extensions.length} published extension
+ {extensions.length === 1 ? '' : 's'} under your developer profile.
+ Transfer ownership or remove them from{' '}
+ your developer profile before
+ deleting your account.
+
+ ) : (
+
+
+ This permanently deletes your account
+ {developer && ' and your developer profile'}. This can't be undone.
+
+
+
Your personal profile (display name, bio) is removed.
+ {developer && (
+
+ Your developer profile ({developer.name}) and its public page
+ are removed; the ID may be used by someone else afterward.
+
+
diff --git a/src/pages/account/developer/index.astro b/src/pages/account/developer/index.astro
index 54263025..352fe5ff 100644
--- a/src/pages/account/developer/index.astro
+++ b/src/pages/account/developer/index.astro
@@ -3,7 +3,7 @@ import Base from '../../../layouts/Base.astro';
import { env } from 'cloudflare:workers';
import { requireUser } from '@/lib/auth-guard';
import { createApiClient, ApiRequestError } from '@/lib/apiClient';
-import { getDeveloperByOwner } from '@/lib/database';
+import { getDeveloperByOwner, getExtensionsByOwner } from '@/lib/database';
import { buildDeveloperProfile } from '@/lib/developer-form';
import { DEVELOPER_TYPES } from '@/types';
import type {
@@ -19,6 +19,13 @@ const user = guard;
let developer = await getDeveloperByOwner(env.DB_EXTENSIONS, user.sub);
const isEdit = Boolean(developer);
+// Gates both the delete-profile action below and full account deletion
+// (/account/delete) — a developer profile with published extensions can't
+// be removed without orphaning them first.
+const extensions = isEdit
+ ? await getExtensionsByOwner(env.DB_EXTENSIONS, user.sub)
+ : [];
+
const api = createApiClient(env, user.sub);
let error: string | null = Astro.url.searchParams.get('error');
@@ -43,6 +50,20 @@ if (Astro.request.method === 'POST') {
? e.message
: 'Unable to create a transfer link. Please try again.';
}
+ } else if (form.get('intent') === 'delete' && developer) {
+ // extensions.length > 0 falls through here as a no-op — the danger-zone
+ // card below already explains why, so no separate error is needed.
+ if (extensions.length === 0) {
+ try {
+ await api.deleteDeveloperProfile();
+ return Astro.redirect('/account?profile_deleted=1');
+ } catch (e) {
+ error =
+ e instanceof ApiRequestError
+ ? e.message
+ : 'Unable to delete your developer profile. Please try again.';
+ }
+ }
} else {
const payload = buildDeveloperProfile(form, developer);
submitted = payload;
@@ -325,5 +346,41 @@ if (!isEdit) {
)
}
+
+ {
+ isEdit && (
+
+
+ Delete developer profile
+
+ {extensions.length > 0 ? (
+
+ You have {extensions.length} published extension
+ {extensions.length === 1 ? '' : 's'} under this profile. Transfer
+ ownership or remove them before deleting it.
+
+ ) : (
+ <>
+
+ Permanently deletes your publisher identity (name, bio, contact
+ info) and its public page. This can't be undone, and the ID may
+ be used by someone else afterward.
+
From c3165c1d5913057e2bf51063d783124f05651aed Mon Sep 17 00:00:00 2001
From: Adam Daley
Date: Sun, 26 Jul 2026 21:40:30 +0100
Subject: [PATCH 14/14] Fix issues from second automated PR review pass
- requireUser now verifies the users row still exists, closing the gap
where a session on another device survives account deletion for up
to 30 days
- edit-mode submissions always use the verified extension id, never
the form's own extension_id
- extension type/source type are validated against known enums; a new
(non-edit) submission with no release now fails validation instead
of publishing empty version/download_url fields
- empty developer id on create is rejected instead of reaching the API
- public developer reads are typed without contact_email so a future
change can't leak it without a type error
- logout rejects a missing Origin header, not just a mismatched one
- removed the unused offline_access OAuth scope
- apiClient synthesizes a clean ApiRequestError on a non-JSON upstream
response instead of throwing a raw SyntaxError
- extension edit form preserves submitted values on a rejected edit
Claude-Session: https://claude.ai/code/session_01GP7BWksJf2KBFc2RGirEyN
---
src/lib/apiClient.ts | 12 +++++--
src/lib/auth-guard.ts | 27 +++++++++++-----
src/lib/database.ts | 3 +-
src/lib/developer-form.ts | 11 ++++++-
src/lib/oauth.ts | 2 +-
src/lib/submission-form.ts | 33 +++++++++++++++++---
src/lib/users.ts | 11 +++++++
src/pages/account/developer/index.astro | 11 ++++---
src/pages/account/extensions/[id]/edit.astro | 27 +++++++++++++++-
src/pages/auth/logout.ts | 6 ++--
src/types/index.ts | 5 +++
11 files changed, 124 insertions(+), 24 deletions(-)
diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts
index a90acc48..be59eb37 100644
--- a/src/lib/apiClient.ts
+++ b/src/lib/apiClient.ts
@@ -40,8 +40,16 @@ export function createApiClient(env: Cloudflare.Env, sub: string) {
},
);
- const body = (await response.json()) as
- { result: T } | { error: { code: string; message: string } };
+ let body: { result: T } | { error: { code: string; message: string } };
+ try {
+ body = await response.json();
+ } catch {
+ throw new ApiRequestError(
+ response.status,
+ 'invalid_response',
+ 'The extensions API returned an unexpected response.',
+ );
+ }
if (!response.ok) {
const { error } = body as { error: { code: string; message: string } };
diff --git a/src/lib/auth-guard.ts b/src/lib/auth-guard.ts
index d6eb2296..00929ee3 100644
--- a/src/lib/auth-guard.ts
+++ b/src/lib/auth-guard.ts
@@ -1,6 +1,6 @@
import type { AstroCookies } from 'astro';
-import { getSessionUser, type SessionUser } from './session';
-import { isModerator } from './users';
+import { getSessionUser, SESSION_COOKIE, type SessionUser } from './session';
+import { isModerator, userExists } from './users';
// Structural subset shared by AstroGlobal (in .astro pages) and the
// destructured APIContext (in .ts API routes), so guards work in both.
@@ -20,13 +20,26 @@ export async function requireUser(
context: AuthContext,
env: Cloudflare.Env,
): Promise {
+ const redirectToLogin = () => {
+ const redirectTo = encodeURIComponent(
+ context.url.pathname + context.url.search,
+ );
+ return context.redirect(`/auth/login?redirect=${redirectTo}`);
+ };
+
const user = await getSessionUser(context.cookies, env.SESSION_SECRET);
- if (user) return user;
+ if (!user) return redirectToLogin();
+
+ // A signed cookie can outlive the account it names by up to
+ // SESSION_MAX_AGE — e.g. another device's session after /account/delete,
+ // or a failed post-login upsert — so verify the row still exists rather
+ // than trusting the cookie alone.
+ if (!(await userExists(env.DB_EXTENSIONS, user.sub))) {
+ context.cookies.delete(SESSION_COOKIE, { path: '/' });
+ return redirectToLogin();
+ }
- const redirectTo = encodeURIComponent(
- context.url.pathname + context.url.search,
- );
- return context.redirect(`/auth/login?redirect=${redirectTo}`);
+ return user;
}
export async function requireModerator(
diff --git a/src/lib/database.ts b/src/lib/database.ts
index 61e9842b..8b1f8016 100644
--- a/src/lib/database.ts
+++ b/src/lib/database.ts
@@ -4,6 +4,7 @@ import {
type Extension,
type Developer,
type DeveloperProfile,
+ type PublicDeveloperProfile,
type Release,
type Repository,
} from '@/types';
@@ -153,7 +154,7 @@ export async function getDeveloperByOwner(
export async function getDeveloperById(
db: D1Database,
id: string,
-): Promise {
+): Promise {
let row;
try {
row = await db
diff --git a/src/lib/developer-form.ts b/src/lib/developer-form.ts
index b7ba0e24..eadf78a3 100644
--- a/src/lib/developer-form.ts
+++ b/src/lib/developer-form.ts
@@ -6,6 +6,11 @@ function isDeveloperType(value: string): value is Developer['type'] {
return (DEVELOPER_TYPES as readonly string[]).includes(value);
}
+// Thrown for form-level validation failures that should be shown to the
+// user, distinct from ApiRequestError (the api rejecting an otherwise
+// well-formed payload) — see the try/catch in account/developer/index.astro.
+export class DeveloperValidationError extends Error {}
+
// Builds a DeveloperProfileInput from the developer-profile form, for
// PUT /developers/me. The id is immutable once a developer profile exists —
// see the readonly id field in /account/developer.
@@ -15,9 +20,13 @@ export function buildDeveloperProfile(
): DeveloperProfileInput {
const str = (name: string) => formString(form, name);
const typeInput = str('type');
+ const id = existingDeveloper?.id ?? str('id').toLowerCase();
+ if (!id) {
+ throw new DeveloperValidationError('Publisher ID is required.');
+ }
return {
- id: existingDeveloper?.id ?? (str('id').toLowerCase() as Lowercase),
+ id: id as Lowercase,
type: isDeveloperType(typeInput) ? typeInput : 'user',
name: str('name'),
URL: str('url') || undefined,
diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts
index 743998c8..a4dba215 100644
--- a/src/lib/oauth.ts
+++ b/src/lib/oauth.ts
@@ -8,7 +8,7 @@ const AUTHORIZE_ENDPOINT = `${ISSUER}/oauth2/authorize`;
const TOKEN_ENDPOINT = `${ISSUER}/oauth2/token`;
const USERINFO_ENDPOINT = `${ISSUER}/oauth2/userinfo`;
-const SCOPE = 'openid profile email offline_access';
+const SCOPE = 'openid profile email';
// Short-lived cookies that carry the PKCE verifier and CSRF state across the
// redirect to the auth service and back. Cleared as soon as the callback
diff --git a/src/lib/submission-form.ts b/src/lib/submission-form.ts
index 0e25f698..309a9b97 100644
--- a/src/lib/submission-form.ts
+++ b/src/lib/submission-form.ts
@@ -1,8 +1,9 @@
+import { EXTENSION_TYPES, SOURCE_TYPES } from '@/types';
import type {
Developer,
Extension,
- ExtensionPayload,
Release,
+ Repository,
SubmissionPayload,
} from '@/types';
import { formString } from './form';
@@ -12,6 +13,14 @@ import { formString } from './form';
// well-formed payload) — see the try/catch in new.astro/edit.astro.
export class SubmissionValidationError extends Error {}
+function isExtensionType(value: string): value is Extension['type'] {
+ return (EXTENSION_TYPES as readonly string[]).includes(value);
+}
+
+function isSourceType(value: string): value is Repository['type'] {
+ return (SOURCE_TYPES as readonly string[]).includes(value);
+}
+
// Builds a SubmissionPayload from the ExtensionSubmissionForm component's
// fields. A new release is only appended when version_tag is filled — for
// edits that's optional (see the form's required={!isEdit}), and the api
@@ -29,6 +38,11 @@ export function buildSubmissionPayload(
let downloadUrl = existingExtension?.download_url ?? '';
const newReleaseTag = str('version_tag');
+ if (!existingExtension && !newReleaseTag) {
+ throw new SubmissionValidationError(
+ 'An initial release (version, release date, and download URL) is required.',
+ );
+ }
if (newReleaseTag) {
const releaseDate = str('release_date');
const releaseDownloadUrl = str('download_url');
@@ -51,11 +65,22 @@ export function buildSubmissionPayload(
downloadUrl = release.download_url;
}
+ const typeInput = str('type');
+ const sourceTypeInput = str('source_type');
+ if (!isExtensionType(typeInput) || !isSourceType(sourceTypeInput)) {
+ throw new SubmissionValidationError(
+ 'Choose a valid extension type and repository host.',
+ );
+ }
+
return {
developer,
extension: {
- id: str('extension_id').toLowerCase(),
- type: str('type') as ExtensionPayload['type'],
+ // In edit mode, always use the id of the already-verified extension —
+ // never the form's own extension_id — so the payload can't be aimed at
+ // a different extension than the one the caller was confirmed to own.
+ id: existingExtension?.id ?? str('extension_id').toLowerCase(),
+ type: typeInput,
name: str('name'),
description: str('description'),
releases,
@@ -67,7 +92,7 @@ export function buildSubmissionPayload(
icon_url: str('icon_url') || undefined,
readme: str('readme'),
source: {
- type: str('source_type') as ExtensionPayload['source']['type'],
+ type: sourceTypeInput,
repo: str('source_repo'),
},
version,
diff --git a/src/lib/users.ts b/src/lib/users.ts
index 42aa431d..301bfaf5 100644
--- a/src/lib/users.ts
+++ b/src/lib/users.ts
@@ -28,6 +28,17 @@ export async function upsertUser(
.run();
}
+export async function userExists(
+ db: D1Database,
+ userId: string,
+): Promise {
+ const row = await db
+ .prepare('SELECT 1 FROM users WHERE id = ?')
+ .bind(userId)
+ .first();
+ return row !== null;
+}
+
export async function isModerator(
db: D1Database,
userId: string,
diff --git a/src/pages/account/developer/index.astro b/src/pages/account/developer/index.astro
index 352fe5ff..0217c8f8 100644
--- a/src/pages/account/developer/index.astro
+++ b/src/pages/account/developer/index.astro
@@ -4,7 +4,10 @@ import { env } from 'cloudflare:workers';
import { requireUser } from '@/lib/auth-guard';
import { createApiClient, ApiRequestError } from '@/lib/apiClient';
import { getDeveloperByOwner, getExtensionsByOwner } from '@/lib/database';
-import { buildDeveloperProfile } from '@/lib/developer-form';
+import {
+ buildDeveloperProfile,
+ DeveloperValidationError,
+} from '@/lib/developer-form';
import { DEVELOPER_TYPES } from '@/types';
import type {
DeveloperClaim,
@@ -65,14 +68,14 @@ if (Astro.request.method === 'POST') {
}
}
} else {
- const payload = buildDeveloperProfile(form, developer);
- submitted = payload;
try {
+ const payload = buildDeveloperProfile(form, developer);
+ submitted = payload;
developer = await api.upsertDeveloperProfile(payload);
return Astro.redirect('/account?profile_saved=1');
} catch (e) {
error =
- e instanceof ApiRequestError
+ e instanceof ApiRequestError || e instanceof DeveloperValidationError
? e.message
: 'Something went wrong saving your developer profile. Please try again.';
}
diff --git a/src/pages/account/extensions/[id]/edit.astro b/src/pages/account/extensions/[id]/edit.astro
index 2e6bb788..617648b1 100644
--- a/src/pages/account/extensions/[id]/edit.astro
+++ b/src/pages/account/extensions/[id]/edit.astro
@@ -9,6 +9,8 @@ import {
buildSubmissionPayload,
SubmissionValidationError,
} from '@/lib/submission-form';
+import { formString } from '@/lib/form';
+import type { Extension } from '@/types';
const guard = await requireUser(Astro, env);
if (guard instanceof Response) return guard;
@@ -30,9 +32,30 @@ if (!developer || developer.id !== extension.developer.id) {
}
let error: string | null = null;
+// Falls back to the just-submitted values on a rejected edit, so the form
+// doesn't reset to the currently-published extension and discard the edit.
+let submitted: Extension | null = null;
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
+ submitted = {
+ ...extension,
+ type: formString(form, 'type') as Extension['type'],
+ name: formString(form, 'name'),
+ description: formString(form, 'description'),
+ website: formString(form, 'website'),
+ icon_url: formString(form, 'icon_url') || undefined,
+ readme: formString(form, 'readme'),
+ license: {
+ name: formString(form, 'license_name'),
+ URL: formString(form, 'license_url') || undefined,
+ },
+ source: {
+ type: formString(form, 'source_type') as Extension['source']['type'],
+ repo: formString(form, 'source_repo'),
+ },
+ };
+
const api = createApiClient(env, user.sub);
try {
const payload = buildSubmissionPayload(form, developer, extension);
@@ -45,6 +68,8 @@ if (Astro.request.method === 'POST') {
: 'Something went wrong submitting your edit. Please try again.';
}
}
+
+const fields = submitted ?? extension;
---
@@ -57,7 +82,7 @@ if (Astro.request.method === 'POST') {
diff --git a/src/pages/auth/logout.ts b/src/pages/auth/logout.ts
index 7e6abdad..5d3d8dcf 100644
--- a/src/pages/auth/logout.ts
+++ b/src/pages/auth/logout.ts
@@ -5,11 +5,11 @@ import { SESSION_COOKIE } from '@/lib/session';
// session to be cleared. SameSite=Lax on the session cookie already stops a
// cross-site POST from carrying it, but the response's Set-Cookie deletion
// would still apply regardless — so check Origin ourselves rather than rely
-// on that. Same-origin requests always send Origin on POST; only reject
-// when it's present and doesn't match.
+// on that. Same-origin POSTs always send Origin, so a missing one is
+// rejected along with a mismatched one.
export const POST: APIRoute = async ({ cookies, redirect, request, url }) => {
const origin = request.headers.get('Origin');
- if (origin && origin !== url.origin) {
+ if (origin !== url.origin) {
return redirect('/');
}
diff --git a/src/types/index.ts b/src/types/index.ts
index b8dcd534..dae91ac6 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -129,6 +129,11 @@ export type DeveloperProfileInput = Omit<
'approved' | 'unclaimed'
>;
+// What getDeveloperById (the public /developer/[id] read) returns —
+// contact_email is owner/moderator-only, so it's excluded at the type level
+// rather than relying solely on the query not selecting it.
+export type PublicDeveloperProfile = Omit;
+
// A snapshot of a developers row as it existed right after one
// PUT /developers/me write — see the api repo's DeveloperHistoryEntrySchema.
// Newest first from GET /extensions/v2/developers/{id}/history