# Build The Urlist

Build the complete application described below in this repository.

Work autonomously from start to finish. Make an internal plan, implement all features, add tests, run validation, fix defects, and continue until the application is complete. If a small implementation detail is not specified, choose a reasonable solution and document it. Do not stop after scaffolding or partial implementation.

Do not access or copy an existing implementation of The Urlist.

## Product

The Urlist lets people create ordered lists of web links and publish each list at one short public URL.

Use this main copy:

> Group links, save and share them with the world.

> Add links to a list and share it with one simple URL.

A person can start a list without an account.

- A signed-out person can publish an anonymous list. After publication, the list is permanent and cannot be edited, deleted, or claimed.
- A signed-in person publishes an account-owned list. Its owner can edit it, soft-delete it, and restore it.
- Anyone can view an active list without signing in.

## Required technology

Use:

- Next.js with the App Router
- React
- TypeScript with strict mode
- Node.js
- npm
- SQLite
- Direct parameterized SQL through `better-sqlite3`
- Vitest for unit and integration tests
- Playwright for browser tests

Do not use:

- An ORM
- A real OAuth provider
- A cloud database
- A required paid service
- Static metadata fixtures in place of live metadata fetching

Use current stable, non-prerelease package versions. Commit `package-lock.json`.

Code that uses SQLite, server sessions, the file system, or metadata fetching must run in the Node.js runtime, not the Edge runtime.

## Required routes

| Route | Access | Behavior |
|---|---|---|
| `/` | Public | Home page and first-link entry |
| `/s/new` | Public | Create or resume the one local draft |
| `/s/edit/{alias}` | Owner only | Load and edit an account-owned list; direct navigation and reload must work |
| `/s/mylists` | Signed in | Show the current user's active and deleted lists |
| `/{alias}` | Public | Show an active list, deleted tombstone, or never-used alias state |

Aliases can contain multiple path segments. For example:

```text
/burke
/burke/links
/teams/web/resources
```

Use App Router catch-all segments where needed so both public and edit routes accept nested aliases.

Static application routes must take priority over public alias routing.

Reserve these first path segments:

- `s`
- `api`
- `auth`
- `__test`
- `.well-known`

Document any additional reserved paths.

## Global navigation

Provide:

- The Urlist home link
- New
- My Lists when signed in
- Light, Dark, and System theme choices
- Login when signed out
- Current user identity and Log out when signed in

At mobile widths, these controls can use a labeled menu.

Selecting New must not silently erase a non-empty draft. Ask for confirmation first.

## Home page

The home page must contain:

- The product name and core copy
- A field for the first link URL
- An Add link action
- Clear validation feedback

When the first valid link is added:

1. Add it to the current draft.
2. Navigate to `/s/new`.
3. Start metadata enrichment.

If a draft already exists, add the link to that draft. Do not clear existing content.

## Drafts

Keep one unpublished draft per browser profile.

Store the draft in browser-local storage. It must not depend on the current server login session. Server metadata responses update the browser-local draft.

The draft must:

- Work for signed-out and signed-in users
- Survive reload and navigation
- Store list description, requested alias, links, order, edited metadata, and completed metadata states
- Remain when the user logs in or out
- Clear after successful publication

Do not clear a non-empty draft without confirmation.

Ownership is decided when the list is published:

- Signed out: anonymous
- Signed in: owned by the current mock user

## Link URL rules

A link destination must be an absolute HTTP or HTTPS URL.

Reject:

- Empty values
- Relative URLs
- Schemes other than HTTP and HTTPS
- Invalid or missing hosts
- URLs with embedded credentials
- Values that are not one valid absolute URL

You may add `https://` to a domain-like value without a scheme. If you do, show the normalized URL before publication.

Show validation errors next to the URL field and associate them with the field.

After a link is added, its destination URL is immutable. Show it as readable text. To change it, the author must delete the link and add a new one.

## Links

A list:

- Must contain at least one link before publication or save
- Has no product-level maximum number of links
- Can contain duplicate destination URLs
- Keeps each duplicate as an independent entry
- Preserves link order

For each link, store:

- Stable ID
- Destination URL
- Title
- Description
- Image URL
- Position
- Metadata status

Authors can:

- Add links
- Delete links
- Edit title
- Edit description
- Reorder links

Authors cannot directly edit:

- Destination URL
- Image URL

Reordering must work with pointer input and keyboard controls. Provide named Move up and Move down actions. Drag-and-drop alone is not sufficient.

## Live metadata enrichment

After a link is added, fetch and parse the live destination page on the server.

Each link must show one metadata state:

- Pending
- Succeeded
- Failed

The network operation must stop no later than 10 seconds after it starts. Show Succeeded or Failed no later than one additional second after the deadline.

Publication and save must wait while any link is Pending.

If enrichment fails:

- Keep the link
- Show a clear failure state
- Let the author edit title and description
- Allow publication or save after the request reaches Failed

Use this metadata precedence:

| Field | First | Second | Fallback |
|---|---|---|---|
| Title | `og:title` | Twitter Card title | HTML `<title>`, then suitable `<h1>` |
| Description | `og:description` | Twitter Card description | HTML meta description |
| Image | `og:image` | Twitter Card image | Declared page icon, then `/favicon.ico` |

Support Twitter Card tags that use `name` or `property`.

Resolve relative images and icons against the final document URL after redirects.

An HTML page with no useful metadata is still a successful enrichment. Title, description, and image are optional.

Show a neutral placeholder if there is no usable image.

If an author edits an enriched title or description, preserve the manual value.

## Metadata-fetch security

The server-side metadata fetcher must protect internal networks.

It must:

- Allow only HTTP and HTTPS
- Reject embedded credentials
- Reject loopback addresses
- Reject private network ranges
- Reject link-local addresses
- Reject multicast, unspecified, and reserved ranges
- Reject cloud instance metadata addresses
- Validate all resolved addresses before connecting
- Validate the scheme, host, and resolved address after every redirect
- Follow no more than five redirects
- Protect against DNS rebinding
- Stop the complete network operation after 10 seconds
- Limit response bodies to 2 MiB or less
- Parse only HTML or XHTML
- Never forward application cookies, authorization headers, or user credentials

Treat remote metadata as untrusted input.

## Alias rules

An alias contains one or more path segments separated by `/`.

Normalize ASCII letters to lowercase before checking or storing the alias.

Each segment:

- Contains only lowercase letters `a-z`, digits `0-9`, and hyphens
- Contains 1 to 50 characters
- Cannot start or end with a hyphen

The complete alias, including separators, cannot exceed 200 characters.

Reject aliases with:

- Empty segments
- Leading or trailing `/`
- Two adjacent `/` characters
- `.` or `..` segments
- Query strings or fragments
- Backslashes
- Encoded slashes or backslashes
- Reserved first segments

Use the exact complete normalized path for lookup. Prefix aliases are independent, so `burke` and `burke/links` can both exist.

An alias is unavailable if it belongs to:

- An active list
- A deleted list
- An anonymous list

If the author leaves the alias blank, generate an available seven-character alias that uses lowercase letters and digits.

Use a database uniqueness constraint as the final collision control. If two requests use the same normalized alias, exactly one can succeed. Never overwrite an existing list.

An alias cannot change after publication.

## Publication

A list description is optional.

Block publication or save when:

- The list has no links
- A metadata request is Pending
- The alias is invalid
- The alias is unavailable

Repeat all validation on the server.

If publication fails:

- Show a clear recoverable error
- Keep the draft
- Do not show false success

After successful publication:

- Clear the draft
- Navigate to the canonical public list path

## Anonymous publication

A signed-out person can publish.

Before writing the list, show a blocking confirmation with this meaning:

> You are not signed in. After publication, you cannot edit or delete this list. Its alias stays reserved permanently.

Use clear actions:

- Publish permanently
- Cancel

Cancel must return to the unchanged draft.

After confirmation, the anonymous list:

- Has no account owner
- Is immutable
- Cannot be deleted through product interfaces or normal product APIs
- Cannot be claimed after login
- Never appears in My Lists
- Keeps its alias reserved permanently

Enforce these rules on the server.

## Mock GitHub login

Provide a local mock GitHub provider. Do not call GitHub and do not implement OAuth.

Provide two fictional users with stable, distinct:

- User IDs
- Display names
- Avatars or placeholders

Show user A first and user B second. Each control must have the accessible name:

```text
Continue as {display name}
```

Login must:

- Create a server-verifiable session
- Survive reload
- Use an HTTP-only cookie with an appropriate SameSite policy or an equivalent safe mechanism
- Use the Secure cookie flag in production over HTTPS; local HTTP development and tests must still work

Logout must end the session.

Use the stable user ID for ownership checks. Never trust a client-supplied owner ID.

Keep authentication behind a clear boundary so a real provider could replace it later without changing ownership rules.

## Account-owned lists

A list published while signed in belongs to the current user.

Only its owner can:

- Open its edit page
- Save changes
- Delete it
- Restore it

Enforce ownership on the server for every mutation. Hiding controls is not sufficient.

The edit page must:

- Load the list from persistent storage
- Work after direct navigation and reload
- Show alias as read-only
- Allow changes to list description
- Allow adding and deleting links
- Allow changing link order
- Allow editing link titles and descriptions
- Provide Save
- Provide Delete list

If save is asynchronous, show Saving, Saved, and Failed states. Preserve unsaved input after failure.

The alias and existing destination URLs cannot change.

## My Lists

`/s/mylists` requires login.

Show only lists owned by the current user.

Provide:

- Create new list
- Active lists section
- Deleted lists section
- Explicit empty states

Each active list summary must show:

- Alias
- Description when present
- Link count

Selecting an active list opens its edit route.

Each deleted list summary must provide Restore.

## Delete, tombstone, and restore

Delete is a soft delete.

Before deletion, show a confirmation with clear Delete list and Cancel actions.

After deletion:

- Keep all list content and ownership
- Keep the alias reserved
- Move the list to Deleted lists
- Show a public tombstone at the old path

The tombstone must show only:

> This list was deleted.

Do not show the former description, links, owner, or deletion reason.

If the signed-in visitor is the owner, also show Restore.

Other visitors and other signed-in users must not see Restore.

Restoring must reactivate the same:

- List
- Alias
- Content
- Link order
- Ownership

There is no permanent-delete product action.

## Public list page

Anyone can view an active list without logging in.

Use the list description as the heading. If it is blank, use the alias.

Show links in saved order.

Each public link card must show:

- Image or neutral placeholder
- Title, or destination hostname or URL when title is blank
- Description when present
- Destination URL

Opening a destination must use safe new-context behavior such as `noopener` and `noreferrer`.

Provide two views:

- Links
- QR code

Expose the selected view to assistive technology.

## Sharing

Provide:

- Copy link
- X or Twitter share link
- Facebook share link
- LinkedIn share link

Copy link must copy the canonical absolute public URL and announce success or failure.

Platform share links must contain a correctly percent-encoded canonical URL. Include the encoded list heading where the platform supports it.

User content must not inject additional share parameters.

## QR code

Generate a real scannable QR code for the canonical absolute public-list URL.

Requirements:

- A standard decoder recovers the exact URL
- It works in light and dark themes
- It has sufficient contrast
- Adjacent text or an accessible alternative identifies the encoded URL

QR download is not required.

## Never-used aliases

When a valid alias has never been used:

- Show that the list was not found
- Say that the alias is available
- Provide Create a list with this alias

The action must prefill the alias in the draft.

Do not silently overwrite a non-empty draft.

For an invalid or reserved path, show a generic not-found or invalid-path state. Do not offer it as an available alias.

## Themes

Provide:

- Light
- Dark
- System

Persist the selection in the browser.

System follows the operating system preference.

Avoid a visible flash of the wrong theme when practical.

Keep accessible contrast in every theme.

## Responsive behavior

Support current Chromium at desktop and mobile widths down to 320 CSS pixels.

At mobile widths:

- Navigation can collapse into a labeled menu
- Alias, description, and primary action stack vertically
- Link cards keep title, URL, status, and primary actions visible
- My Lists uses one column or an appropriate reduced grid
- Share and view controls remain available
- Modals fit the viewport and can scroll internally

Do not require page-level horizontal and vertical scrolling together.

## Accessibility

The implemented journeys must meet WCAG 2.2 AA.

Requirements:

- One descriptive level-one heading per page
- Logical heading order
- Accessible names for all controls
- Labels and associated errors for form fields
- Programmatic required and invalid states
- Useful image alternatives
- Full keyboard operation
- Visible focus in every theme
- Logical focus order
- Keyboard link reordering
- Status announcements for loading, metadata, save, publish, copy, and errors
- Color is not the only way to show state
- Usable at 200% zoom
- Reflow at 320 CSS pixels
- Respect `prefers-reduced-motion`

A modal must:

- Have a title and modal semantics
- Receive focus when opened
- Trap focus while open
- Close on Escape when cancellation is allowed
- Return focus to its trigger

After delete or reorder, move focus to a logical nearby control.

## State and error handling

Represent:

- Initial loading and fatal failure
- Metadata Pending, Succeeded, and Failed
- Alias checking, available, invalid, and unavailable
- Publication blocked, publishing, success, and failure
- Save progress and failure
- My Lists loading, results, empty, and failure
- Public loading, active, never used, deleted, and failure
- Copy success and failure
- Signed-out, identity selection, signed-in, and login failure

Errors must explain what the user can do next.

Do not turn failures into success-shaped fallbacks.

Preserve user input after failed publish or save.

Use live regions for status changes that need announcement.

If Publish is disabled, provide a discoverable reason.

## Data and persistence

Use SQLite with direct parameterized SQL through `better-sqlite3`.

Provide ordered SQL migrations or a repeatable versioned schema initializer.

Enable SQLite foreign keys for every connection.

Use transactions for:

- Publication
- Save
- Delete
- Restore
- Reset

At minimum, persist:

### User

- Stable user ID
- Provider
- Display name
- Avatar

### List

- Stable list ID
- Normalized immutable alias
- Optional description
- Anonymous or account ownership type
- Owner user ID for account-owned lists
- Active or deleted status
- Created time
- Updated time
- Deleted time when deleted

### Link

- Stable link ID
- Parent list ID
- Immutable destination URL
- Optional title
- Optional description
- Optional image URL
- Position

Published and deleted data must survive a full application restart.

Use an environment variable for the database path and provide a safe development default.

Exclude database files from Git.

## General security

The server must:

- Validate all input independently of the client
- Enforce every ownership rule
- Use parameterized SQL
- Derive ownership from the authenticated session
- Render user and remote text safely
- Prevent stored and reflected script execution
- Encode share parameters safely
- Use CSRF protection appropriate for the mutation design
- Avoid exposing stack traces, secrets, internal paths, or database details

Keep runtime database files, secrets, build output, and dependencies out of Git.

## Test reset

Provide a development/test-only way to:

- Delete all published and deleted lists
- Clear server sessions when practical
- Restore the two stable mock users
- Return to a deterministic clean state
- Run repeatedly with the same result

Prefer:

```http
POST /__test/reset
```

with:

```http
204 No Content
```

Disable or strongly protect reset in production mode.

## Required npm scripts

Provide:

| Script | Purpose |
|---|---|
| `dev` | Start development mode |
| `build` | Create a production build |
| `start` | Start the production build |
| `lint` | Run linting |
| `typecheck` | Run strict TypeScript checks |
| `test` | Run Vitest once |
| `test:e2e` | Run Playwright once |
| `db:init` | Initialize or migrate SQLite |
| `db:reset` | Safely reset development or test data |

Scripts must return a nonzero status on failure. Test scripts must not start in watch mode.

## Tests

Use Vitest for:

- URL validation
- Alias normalization and validation
- Metadata precedence and relative URLs
- SSRF address policy
- Ownership and mutation rules
- SQLite behavior
- Publication, delete, and restore transactions
- Share URL construction

Use Playwright for:

- Anonymous draft and permanent publication
- Login and logout with both users
- Account publication
- My Lists
- Owner editing
- Non-owner isolation
- Delete, tombstone, and restore
- Public Links and QR views
- Theme persistence
- Keyboard operation
- Mobile layout

Tests must use a separate temporary SQLite database, reset their state, avoid order dependence, and clean up started processes.

## README

Document:

- Required Node.js version
- Installation
- Environment variables
- Database initialization
- Development startup
- Production build and startup
- Test commands
- Mock login
- Reset behavior
- Database location
- Live metadata behavior and security limits
- Troubleshooting
- Any assumptions you made

## Completion

Before you finish:

1. Install from the committed lock file.
2. Initialize a clean database.
3. Run `npm run lint`.
4. Run `npm run typecheck`.
5. Run `npm test`.
6. Run `npm run test:e2e`.
7. Run `npm run build`.
8. Start the production build and confirm the application responds.
9. Confirm that active, anonymous, and deleted data survives a complete restart.
10. Review every section of this instruction against the implementation.
11. Fix every defect or omission that you find.
12. Repeat the affected validation.

When complete, report:

- What you implemented
- Important assumptions
- Database and architecture choices
- Exact validation commands and results
- Anything incomplete and the exact reason
