Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ SHARED_LISTS_AUTH_PROVIDER=openai-sites
# comma-separated email addresses allowed to create the first list.
FIRST_OWNER_EMAILS=
ENABLE_FIRST_OWNER_SETUP=true
# Production fail-closed default. Set true only for a deliberate local/demo setup
# where any signed-in user may claim the first list while the database is empty.
ALLOW_ANY_FIRST_OWNER=false

# OpenAI Sites optional server features
ENABLE_ACCESS_AUDIT=false
Expand All @@ -18,6 +21,8 @@ ENABLE_PEOPLE_IMPORT=false

# Optional generic quick-action integration. Disabled by default.
QUICK_ACTION_INTEGRATION_ENABLED=false
# Required when QUICK_ACTION_INTEGRATION_ENABLED=true. Use comma-separated
# browser origins allowed to call the quick-action API. Empty fails closed.
QUICK_ACTION_INTEGRATION_ORIGINS=

# Optional per-user Google Contacts autocomplete. Disabled by default.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ You are working in the public Shared Lists Starter repository.
## Normal Commands

```bash
npm install
npm ci
npm run dev
npm test
npm run build
Expand Down
3 changes: 3 additions & 0 deletions AGENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Edit these files for ordinary setup:

Do not edit `src/app.js`, `src/worker.js`, or the store layer just to change the first owner, host, feedback email, or auth provider.
Do not enable optional features such as access audit, people import, quick-action intake, or private Google Contacts autocomplete unless the user asks for them.
Keep private Google Contacts disabled for real deployments until `docs/PRIVATE_CONTACTS.md` shows the large-sync, OAuth, quota, encryption, disconnect, and recovery gates are complete.

## Pick One Deployment Lane

Expand Down Expand Up @@ -95,6 +96,8 @@ curl -X POST "$APP_URL/api/setup/first-owner" \

This request must be authenticated by the host. Do not spoof production identity headers.

Production setup fails closed unless `FIRST_OWNER_EMAILS` is nonempty. Set `ALLOW_ANY_FIRST_OWNER=true` only for an intentional local demo or disposable test deployment.

## Safety Checks

Before handing back:
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

- Make production auth provider configuration fail closed unless `SHARED_LISTS_AUTH_PROVIDER` is explicitly set to `openai-sites` or `cloudflare-access`.
- Block cross-site state-changing API calls with Origin, Fetch Metadata, and JSON content-type checks.
- Clear private browser cache and session state on sign-out or access loss.
- Make first-owner setup require `FIRST_OWNER_EMAILS` unless `ALLOW_ANY_FIRST_OWNER=true` is explicitly configured.
- Keep Google Contacts and Cloudflare deployment lanes documented as gated until their production readiness work is complete.
- Make Quick Actions fail closed unless explicitly enabled with a nonempty origin allowlist.
- Update `esbuild` past `0.27.3`.

## 0.1.0

- Initial public starter export.
Expand Down
2 changes: 1 addition & 1 deletion CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Shared Lists Starter should be a practical place for people to learn, fork, and

For ordinary moderation concerns, open a GitHub issue or discussion and keep the report factual.

For a conduct report that includes private personal information, use GitHub private vulnerability reporting and prefix the title with `Conduct report`:
For a conduct report that includes private personal information, use the dedicated confidential conduct-reporting route when the maintainer has configured one. Until then, use GitHub private vulnerability reporting and prefix the title with `Conduct report`:

```text
https://github.com/CBruney/shared-lists-starter/security/advisories/new
Expand Down
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Edit `shared-lists.config.json` for user-visible and client-side settings:
}
```

Use host environment variables for server-side settings. For local development, copy `.env.example` to `.env`; the dev server loads `.env` automatically. Do not commit real secrets or private deployment IDs. Optional admin and integration surfaces, including access audit, people import, quick-action intake, and private Google Contacts autocomplete, are disabled by config until you turn them on.
Use host environment variables for server-side settings. For local development, copy `.env.example` to `.env`; the dev server loads `.env` automatically. Do not commit real secrets or private deployment IDs. Optional admin and integration surfaces, including access audit, people import, quick-action intake, and private Google Contacts autocomplete, are disabled by config and readiness gates until you intentionally turn them on. Quick-action intake also requires an explicit origin allowlist when enabled.

Set `publicUrl` and `feedbackEmail` only when you have real values. If `feedbackEmail` is empty, the app hides Feedback and Help/questions.

Expand Down Expand Up @@ -127,19 +127,26 @@ Set this server-side value before the first production deploy:

```text
FIRST_OWNER_EMAILS=
ALLOW_ANY_FIRST_OWNER=false
```

Set `FIRST_OWNER_EMAILS` to the real email address, or comma-separated real email addresses, allowed to create the first list. Leave it blank only if any signed-in user should be allowed to claim the first list while the database is empty.
Set `FIRST_OWNER_EMAILS` to the real email address, or comma-separated real email addresses, allowed to create the first list. Production setup fails closed when this value is blank.

Then sign in as that email and either create the first list in the app UI or call the setup endpoint. Set `APP_URL` to the deployed app URL and `FIRST_LIST_TITLE` to the real list name before running this command:
Use `ALLOW_ANY_FIRST_OWNER=true` only for a deliberate local demo or disposable test deployment where any signed-in user may claim the first list while the database is empty.

Then sign in as an allowed email and either create the first list in the app UI or call the setup endpoint through an authenticated host session.

OpenAI Sites API example:

```bash
curl -X POST "$APP_URL/api/setup/first-owner" \
-H "content-type: application/json" \
-d "{\"title\":\"$FIRST_LIST_TITLE\"}"
```

If no `FIRST_OWNER_EMAILS` value is set, any signed-in user may claim first-owner setup while the database has no lists. Once the first list exists, the setup endpoint returns `409`.
Run the command from a session or client authenticated as an email in `FIRST_OWNER_EMAILS`. Do not spoof production identity headers.

Once the first list exists, the setup endpoint returns `409`.

You can also claim setup by signing in as an allowed owner and creating the first list from the app UI.

Expand Down Expand Up @@ -167,7 +174,9 @@ Open the list, tap Share, add the person's email, then copy or send the list lin

### Can sharing autocomplete use my contacts?

Optionally, yes. Private Google Contacts autocomplete is off by default. If a deployer enables it and a user connects their own Google account in Settings, only that user sees those private suggestions. Everyone can still share by typing a full email address.
Optionally, yes, but it is intentionally gated right now. Private Google Contacts autocomplete is off by default and should stay off until the large-sync, OAuth, quota, encryption, disconnect, and recovery checks in [`docs/PRIVATE_CONTACTS.md`](docs/PRIVATE_CONTACTS.md) pass against real D1.

Everyone can still share by typing a full email address.

The shared people directory is scoped to people who already share at least one list with the signed-in user. The app does not send the full global user table to every browser.

Expand Down Expand Up @@ -196,6 +205,7 @@ Important follow-ups before a production Cloudflare release:

- Make the Cloudflare instructions executable.
- Test the production system, not just its components.
- Complete the Cloudflare sign-in/sign-out, security-header, migration, smoke-test, and rollback gates.
- Add a hosted demo only after it has dedicated non-production storage and synthetic data.

### Which license does this use?
Expand Down Expand Up @@ -233,4 +243,6 @@ Never commit `.env`, `wrangler.toml` with real IDs, access tokens, production da

For public repository settings, see [`docs/GITHUB_SETUP.md`](docs/GITHUB_SETUP.md).

For a linked documentation map, see [`docs/INDEX.md`](docs/INDEX.md).

For questions, use GitHub Discussions or the Help/questions action when a deployer has configured a support email.
47 changes: 47 additions & 0 deletions db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const schemaStatements = [
deleted_at TEXT,
deleted_by_email TEXT,
delete_reason TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
revision INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (list_id) REFERENCES lists(id),
FOREIGN KEY (created_by_email) REFERENCES users(email),
Expand Down Expand Up @@ -75,6 +76,24 @@ export const schemaStatements = [
FOREIGN KEY (requester_email) REFERENCES users(email),
FOREIGN KEY (resolved_by_email) REFERENCES users(email)
)`,
`CREATE TABLE IF NOT EXISTS idempotency_keys (
scope TEXT PRIMARY KEY NOT NULL,
status INTEGER NOT NULL,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS task_external_refs (
owner_email TEXT NOT NULL,
source TEXT NOT NULL,
external_id TEXT NOT NULL,
task_id TEXT NOT NULL,
list_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (owner_email, source, external_id),
FOREIGN KEY (owner_email) REFERENCES users(email),
FOREIGN KEY (task_id) REFERENCES tasks(id),
FOREIGN KEY (list_id) REFERENCES lists(id)
)`,
`CREATE TABLE IF NOT EXISTS user_contact_sources (
owner_email TEXT NOT NULL,
provider TEXT NOT NULL,
Expand Down Expand Up @@ -111,4 +130,32 @@ export const schemaStatements = [
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_email) REFERENCES users(email)
)`,
`CREATE INDEX IF NOT EXISTS idx_lists_owner_email ON lists(owner_email)`,
`CREATE INDEX IF NOT EXISTS idx_list_members_email ON list_members(email)`,
`CREATE INDEX IF NOT EXISTS idx_list_members_email_list ON list_members(email, list_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_list_status ON tasks(list_id, status)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_list_status_deleted ON tasks(list_id, status, deleted_at)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_open_list_due_created
ON tasks(list_id, due_date, created_at)
WHERE status = 'open' AND deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_tasks_completed_list_completed
ON tasks(list_id, completed_at DESC, updated_at DESC)
WHERE status = 'completed' AND deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_tasks_open_list_sort_order
ON tasks(list_id, sort_order, created_at)
WHERE status = 'open' AND deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_activity_list_created ON activity(list_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_activity_list_created_id ON activity(list_id, created_at DESC, id DESC)`,
`CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created_at ON idempotency_keys(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_list_access_requests_list_status
ON list_access_requests(list_id, status, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_list_access_requests_requester_status
ON list_access_requests(requester_email, status)`,
`CREATE INDEX IF NOT EXISTS idx_task_external_refs_task ON task_external_refs(task_id)`,
`CREATE INDEX IF NOT EXISTS idx_task_external_refs_list ON task_external_refs(list_id)`,
`CREATE INDEX IF NOT EXISTS idx_user_contacts_owner_provider_name
ON user_contacts(owner_email, provider, display_name)`,
`CREATE INDEX IF NOT EXISTS idx_contact_oauth_states_expires
ON contact_oauth_states(expires_at)`,
];
9 changes: 7 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ SHARED_LISTS_AUTH_PROVIDER=openai-sites
DEV_DEFAULT_USER_EMAIL=
FIRST_OWNER_EMAILS=
ENABLE_FIRST_OWNER_SETUP=true
ALLOW_ANY_FIRST_OWNER=false
ENABLE_ACCESS_AUDIT=false
ACCESS_AUDIT_ADMINS=
ENABLE_PEOPLE_IMPORT=false
Expand All @@ -58,6 +59,10 @@ CLOUDFLARE_ACCESS_AUD=

Set blank values in the host environment before production deploy. `DEV_DEFAULT_USER_EMAIL` is for local development only. `FIRST_OWNER_EMAILS` and `ACCESS_AUDIT_ADMINS` should contain real email addresses for the people allowed to use those actions. Cloudflare values should come from the Cloudflare Access application you create for this app.

`SHARED_LISTS_AUTH_PROVIDER` is required outside local development. Missing or unknown values fail closed.

`FIRST_OWNER_EMAILS` is required before a production first-owner setup. `ALLOW_ANY_FIRST_OWNER=true` is an explicit opt-in for local demos or disposable test deployments where any signed-in user may claim the first list while the database is empty.

For local development, copy `.env.example` to `.env`; `npm run dev` loads `.env` automatically. Production hosts should use their native secret or environment-variable store.

## Auth Provider Values
Expand All @@ -72,8 +77,8 @@ The configured provider is exclusive in production. OpenAI Sites headers are not

- `ENABLE_ACCESS_AUDIT`: exposes the admin access-audit endpoint when set to `true`.
- `ENABLE_PEOPLE_IMPORT`: exposes the admin people-import page and API when set to `true`.
- `QUICK_ACTION_INTEGRATION_ENABLED`: exposes the generic quick-action integration when set to `true`.
- `GOOGLE_CONTACTS_ENABLED`: exposes optional per-user Google Contacts autocomplete when set to `true` and when `features.privateGoogleContacts` is also `true`.
- `QUICK_ACTION_INTEGRATION_ENABLED`: exposes the generic quick-action integration when set to `true`. `QUICK_ACTION_INTEGRATION_ORIGINS` must also contain an explicit comma-separated browser origin allowlist. An empty allowlist fails closed.
- `GOOGLE_CONTACTS_ENABLED`: exposes optional per-user Google Contacts autocomplete when set to `true` and when `features.privateGoogleContacts` is also `true`. Keep this disabled until the readiness gates in `PRIVATE_CONTACTS.md` are complete.

Keep optional features off until you know you need them.

Expand Down
14 changes: 14 additions & 0 deletions docs/DEPLOY_CLOUDFLARE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Not done yet:
- Pin and document the supported Wrangler version.
- Confirm `wrangler.toml.example` matches current Wrangler migration configuration.
- Replace OpenAI Sites sign-in and sign-out paths with provider-correct Cloudflare behavior.
- Confirm `assets.run_worker_first` behavior for API routes and static assets.
- Add security headers for CSP, `frame-ancestors`, `nosniff`, referrer policy, and permissions policy.
- Run a disposable production-like Worker + D1 rehearsal that covers migrations, authentication, first-owner setup, list permissions, deployment, and rollback.

Use the steps below as a checklist for what the lane should do, not as a guaranteed copy-paste deployment guide.
Expand Down Expand Up @@ -59,6 +61,7 @@ Use the steps below as a checklist for what the lane should do, not as a guarant
CLOUDFLARE_ACCESS_TEAM_DOMAIN = ""
CLOUDFLARE_ACCESS_AUD = ""
FIRST_OWNER_EMAILS = ""
ALLOW_ANY_FIRST_OWNER = "false"
```

Use the real team domain and audience value from the Cloudflare Access application. Set `FIRST_OWNER_EMAILS` to the real email address, or comma-separated real email addresses, allowed to create the first list.
Expand Down Expand Up @@ -109,3 +112,14 @@ Use the steps below as a checklist for what the lane should do, not as a guarant
The Worker verifies the Cloudflare Access JWT before trusting an email address. Set both the team domain and application audience value.

Do not commit `wrangler.toml` if it contains real account, database, domain, or policy values.

## Release Gates

Do not advertise Cloudflare support as complete until all of these are true:

- `wrangler` is added to dev dependencies or otherwise pinned to a documented version.
- `npm` scripts exist for build, deploy, migrations, smoke test, and rollback.
- `wrangler.toml.example` is confirmed against the pinned Wrangler version, including `migrations_dir` and asset routing.
- Cloudflare sign-in and sign-out behavior is provider-correct and covered by tests or a rehearsal.
- The Worker sends production security headers.
- A disposable Worker + D1 rehearsal has passed migrations, auth, first-owner restrictions, list permissions, deploy, smoke, and rollback.
45 changes: 42 additions & 3 deletions docs/DEPLOY_OPENAI_SITES.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Use this lane when you want the shortest path from Codex to a hosted app.
```text
FIRST_OWNER_EMAILS=
ENABLE_FIRST_OWNER_SETUP=true
ALLOW_ANY_FIRST_OWNER=false
SHARED_LISTS_AUTH_PROVIDER=openai-sites
```

Expand Down Expand Up @@ -53,18 +54,27 @@ Use this lane when you want the shortest path from Codex to a hosted app.

A signed-out request should return `401`.

9. Sign in through the browser as an email listed in `FIRST_OWNER_EMAILS`.
9. Verify that caller-supplied identity headers do not grant access. A signed-out request with an `oai-authenticated-user-email` header should still be rejected or ignored by Sites, not treated as a signed-in user.

10. Create the first list in the app UI.
```bash
curl -i "$APP_URL/api/session" \
-H "oai-authenticated-user-email: spoofed@example.test"
```

10. Sign in through the browser as an email listed in `FIRST_OWNER_EMAILS`.

Agents or authenticated API clients can also claim first owner with the setup endpoint. Set `APP_URL` to the deployed app URL and `FIRST_LIST_TITLE` to the real list name before running this command:
11. Create the first list in the app UI.

Agents or authenticated API clients can also claim first owner with the setup endpoint. Set `APP_URL` to the deployed app URL and `FIRST_LIST_TITLE` to the real list name before running this command from an authenticated host session:

```bash
curl -X POST "$APP_URL/api/setup/first-owner" \
-H "content-type: application/json" \
-d "{\"title\":\"$FIRST_LIST_TITLE\"}"
```

Do not spoof production identity headers. Use the real provider-authenticated browser/session path.

## Access Model

For a family or small group app, the normal Sites setup is:
Expand All @@ -74,3 +84,32 @@ For a family or small group app, the normal Sites setup is:
- List-level permissions in the app.

That means a person can open the app URL, but they cannot see a list unless they are a member of that list.

## Reproducible Sites Procedure

Before staging:

- Confirm `.openai/hosting.json` points to the intended Sites project, or has no `project_id` when creating a new reusable starter site.
- Confirm the Sites database binding matches the app's D1-style storage binding expected by the project.
- Confirm all migrations in `drizzle/` are included in the saved version.
- Confirm `SHARED_LISTS_AUTH_PROVIDER=openai-sites`.
- Confirm `FIRST_OWNER_EMAILS` is nonempty for production staging, unless this is a disposable test with `ALLOW_ANY_FIRST_OWNER=true`.
- Keep optional features disabled unless intentionally testing them.

Staging checklist:

- Signed-out `/api/session` returns `401`.
- Signed-out `/api/session` with a caller-supplied identity header does not return that supplied identity.
- Signed-in `/api/session` returns the provider identity.
- A signed-in user outside `FIRST_OWNER_EMAILS` cannot create the first list.
- A signed-in user inside `FIRST_OWNER_EMAILS` can create the first list.
- A member can see only lists where they are a member.
- A non-member with a list URL sees no list data and can request access if that flow is enabled.
- Member add/remove and `can_share` changes preserve the share-panel state without a full-page reset.
- Sign-out clears private browser cache and returns to a sign-in-required state with a visible sign-in action.

Rollback:

- Keep the previous saved Sites version available until staging passes.
- If verification fails, redeploy the previous known-good saved version.
- Do not run destructive database migrations as part of rollback. Use additive migrations and follow the database backup policy for data recovery.
Loading
Loading