Vibecode Zenfolio
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Zenfolio, publish a portfolio and client galleries with basic downloads. The hard boundary is hosting, templates, proofing, commerce, print labs, and photographer support, plus image pipeline quality, models, and workflow polish.
You are building a lean indie version of Zenfolio. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Zenfolio · indie build A local photo catalogue and delivery tool whose hard problems are never writing to an original, building previews for tens of thousands of files without falling over on the large ones, and selling a download in a way that survives a refund. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Index files in place by path and content hash, never copy or move them | the originals are the irreplaceable thing and this tool is a visitor | | Previews | A derivative cache keyed by hash and size, built by a queue that records failures | one corrupt file in twelve thousand must not stop the import | | Edits | Adjustment parameters stored per asset and per version, applied at render time | non-destructive means the file on disk is untouched, not that you kept a backup | | Delivery | Galleries served behind an unguessable token with an expiry and a download policy | a client link gets forwarded, and that is the design assumption | | Payments | A hosted checkout, delivery only after settlement, licence text stored on the order | a refund costs you the processing fee, so do not deliver on optimism | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the catalogue, the preview queue and the gallery server. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An image processing library with a documented input pixel limit** · free - Why: Every preview and every rendered export goes through it, and its default limit and failure behaviour are what phase two is built around. - Get it: Install it for your runtime and read its constructor options before writing the queue. - Verify: A deliberately oversize image is refused with the limit named, and the queue records it as a failure - [ ] **A real photo library, or a copy of one, with at least a few thousand files** · free, plus disk for derivatives - Why: Every hard part here is a scale problem, and a folder of twenty test images will tell you nothing about the preview queue or the integrity scan. - Get it: Point the catalogue at a copy of a real shoot, including a panorama and at least one file you know is corrupt. - Verify: A full import and preview build completes and reports its failures - [ ] **A payment processor account with hosted checkout and webhooks** · a percentage plus a fixed fee per sale; the fee is not returned on a refund - Why: Selling downloads means settlement events, refunds and fees, and all three shape the delivery design. - Get it: Create the account, complete verification, and take the keys plus the webhook signing secret. - Verify: A test payment settles and your endpoint receives a signed event - [ ] **Where you are registered to charge tax, and what your refund policy says** · free to decide; possibly not free to comply with - Why: You are the merchant of record, and deciding this after the first international sale means deciding it retrospectively. - Get it: Check the registration thresholds for the places your buyers are, write your refund policy, and put both in the checkout. - Verify: The checkout shows tax where you charge it and the refund policy text is stored with each order ## Quick start ```sh npm run backup npm run restore -- --verify ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Print sales and laboratory fulfilment. - A raw conversion engine of your own. - Hosted synchronisation between devices. - Automatic culling and retouching models. - hosting, templates, proofing, commerce, print labs, and photographer support - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem If one of those is essential to you, that is the reason to keep paying for Zenfolio, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Zenfolio The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a photo catalogue that publishes client galleries and sells a download without ever touching your originals to replace Zenfolio. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first is the reason this project exists. The originals are never modified, never moved and never rewritten. The catalogue indexes files where they already sit, records a hash and stores every adjustment as parameters. Open originals read-only wherever the platform lets you, and write a test that hashes every original before a full import, preview build and edit session, and compares afterwards. If your renderer can overwrite the only copy of somebody's wedding, no other feature in this build matters. Second, taking money makes you the merchant of record. Refunding a sale does not return what the payment cost you: the processor's own documentation states that its processing fees from the original transaction are not returned. Work out whether you must register for sales tax or value added tax where your buyers are before your first sale, because a tax calculation service computes a number but does not make you registered anywhere. And a printed order cannot be un-printed, which is why prints are out of scope here: sell downloads, deliver only after the payment has settled, and put the licence wording into the order. ### Stack (fixed, do not substitute) - Catalogue: Index files in place by path and content hash, never copy or move them · the originals are the irreplaceable thing and this tool is a visitor - Previews: A derivative cache keyed by hash and size, built by a queue that records failures · one corrupt file in twelve thousand must not stop the import - Edits: Adjustment parameters stored per asset and per version, applied at render time · non-destructive means the file on disk is untouched, not that you kept a backup - Delivery: Galleries served behind an unguessable token with an expiry and a download policy · a client link gets forwarded, and that is the design assumption - Payments: A hosted checkout, delivery only after settlement, licence text stored on the order · a refund costs you the processing fee, so do not deliver on optimism ### Data model (create this before Phase 1) - assets(id, path, sha256, bytes, width, height, taken_at, orientation, camera, missing_since) · a path and a hash, and nothing that could be used to write to the file - previews(asset_id, width, format, path, built_at, failed_reason) · a failure is a recorded row, not a stopped import - edits(asset_id, version, params_json, created_at) · crop, exposure, contrast, white balance and saturation as numbers - galleries(id, name, client_name, token_hash, expires_at, download_policy, watermark, bytes_served) - gallery_assets(gallery_id, asset_id, position) and selections(id, gallery_id, asset_id, chosen_by, chosen_at, note) - orders(id, gallery_id, buyer_email, amount_minor, tax_minor, fee_minor, state, settled_at, delivered_at, licence_text) - downloads(order_id, asset_id, token_hash, expires_at, used_at, bytes) · one use, one record, so a forwarded link is visible - Nothing in this schema writes to an original. The only file paths stored for originals are read paths, and the test suite hashes them before and after every run. ### Phase 1 · A catalogue that cannot touch your photographs Build: Walk the chosen folders, record path, size, dimensions, capture time, orientation and a content hash, opening files read-only. Detect duplicates by hash. Detect moved files by hash and repair the path rather than losing the edits. Mark missing files rather than deleting their rows. Trap: A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. Done when: Hashing every original before and after a full import, preview build and edit session shows no change, run as a test over a fixture library. A file moved between folders is matched by hash and its edits follow it, tested. A missing file is marked missing and its rows and edits survive, tested. Duplicate files are identified by hash and shown together without either being deleted. Do not build yet: No previews, edits, galleries or selling yet. ### Phase 2 · Previews for twelve thousand files Build: Build previews at the sizes the interface uses, keyed on hash and width. Set the decoder's input pixel limit explicitly rather than accepting the default, since a stitched panorama or a large scan will exceed it. Record a failure per file and carry on. Resume an interrupted build where it stopped. Report progress and the failures at the end. Trap: Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. Done when: An import containing a file over the configured pixel limit records a failure with the reason and completes the rest, tested with a generated oversize file. A corrupt file produces a recorded failure rather than stopping the queue, tested. Interrupting the build and restarting resumes without rebuilding completed derivatives, tested. The configured pixel limit appears in the settings and in the README with its value. Do not build yet: No edits, galleries, proofing or selling yet. ### Phase 3 · Galleries and proofing that remember who chose Build: A gallery is an ordered list of assets, not a copy of them. Serve it at a link containing an unguessable token, stored hashed, with an expiry and a download policy. Clients mark favourites and leave notes, recorded with a name they enter and a timestamp. Show the photographer a selection list that can be exported and never silently changes. Trap: Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. Done when: A gallery link is a hashed token with an expiry, and an expired link returns a clear message rather than the photographs, tested. Revoking a link makes it stop working immediately while the gallery and its selections survive, tested. Every selection records who made it and when, and the list is exportable. Bytes served are counted per gallery and visible to the photographer. Do not build yet: No selling, backup or export yet. ### Phase 4 · Selling a download honestly Build: A hosted checkout for a download of one or more images, with the price and tax shown first. Grant a single-use download token only after the payment has settled. Store the licence wording with the order. Record the processing fee on the order so your accounting shows what a refund actually costs. Refunding revokes any unused download token. Trap: Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. Done when: No download token exists before the payment has settled, tested with a delayed settlement event. A download token is single use and its use is recorded with the byte count, tested by trying it twice. The licence wording is stored on the order and shown in the receipt. Refunding revokes any unused token and the order shows the processing fee that was not returned. Do not build yet: No backup, bandwidth ceiling or export yet. ### Phase 5 · Backups, ceilings and leaving Build: Back up the catalogue database on a schedule with a tested restore. Export edit parameters, gallery definitions, selections and orders as documented JSON alongside a manifest of original paths and hashes. Put a monthly bytes-served ceiling on public galleries with a clear message when reached. Run an integrity scan that verifies hashes and reports files that changed underneath you. Trap: Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare. Done when: A restore from backup reconnects to the original files by hash and reports anything it cannot find, tested by moving files before restoring. The integrity scan detects a file whose content changed since import and reports it without modifying anything, tested. The exported JSON is documented and reimports into an empty catalogue with the same edits and galleries, tested. Reaching the monthly bytes-served ceiling stops public gallery serving with a message and leaves the local catalogue working. Do not build yet: Nothing after this. ### Out of scope (and why) - Print sales and laboratory fulfilment, because a printed order cannot be reversed and the refund, the postage and the remake all come out of your money rather than the platform's. - A raw conversion engine of your own, which is years of work and is the one part where the paid tools are genuinely ahead. - Hosted synchronisation between devices, which turns a local catalogue into a service with somebody else's photographs on your disk. - Automatic culling and retouching models, which need hardware, a model you did not train and a claim about quality you cannot support. ### README must contain - That originals are opened read-only, never modified or moved, and that the test suite hashes them before and after every run to check it - That the decoder's input pixel limit is set deliberately, with the number stated, and what happens to a file that exceeds it - That gallery links are hashed tokens with an expiry, can be revoked, and that bytes served are counted per gallery - That a download is delivered only after the payment settles, that the licence wording is stored on the order, and that a refund does not return the processing fee - What the backup contains, how a restore reconnects to originals by hash, and what the integrity scan reports ===== AGENTS.md ===== # Agent instructions · Zenfolio indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Index files in place by path and content hash, never copy or move them, A derivative cache keyed by hash and size, built by a queue that records failures, Adjustment parameters stored per asset and per version, applied at render time, Galleries served behind an unguessable token with an expiry and a download policy, A hosted checkout, delivery only after settlement, licence text stored on the order. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. ===== BUILD_PLAN.md ===== # Build plan · Zenfolio A local photo catalogue and delivery tool whose hard problems are never writing to an original, building previews for tens of thousands of files without falling over on the large ones, and selling a download in a way that survives a refund. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A catalogue that cannot touch your photographs Importing indexes files in place, records hashes, and provably leaves every original byte identical. ### Steps 1. Open originals read-only and hash them The hash is both the identity and the proof. Files: `src/catalogue/import.ts`, `tests/originals-unchanged.test.ts` 2. Repair moved files by hash rather than reimporting Otherwise reorganising a folder loses a year of edits. Files: `src/catalogue/repair.ts` 3. Mark missing, never delete An unplugged drive is not a deletion. Files: `src/catalogue/scan.ts` ### Done when - [ ] Originals hash identically before and after everything - [ ] Moved files are matched by hash and keep their edits - [ ] Missing files are marked, not removed - [ ] Duplicates are grouped rather than deleted ### Watch out - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. ## Phase 2 · Previews for twelve thousand files Derivatives build through a queue that survives large files, corrupt files and interruption. ### Steps 1. Set the decoder pixel limit deliberately The default will refuse a panorama and the maximum will exhaust memory. Files: `src/previews/decode.ts`, `docs/limits.md` 2. Record failures per file and keep going Twelve thousand files will contain a few bad ones. Files: `src/previews/queue.ts`, `tests/queue-failure.test.ts` 3. Resume an interrupted build Preview builds take hours and machines restart. Files: `src/previews/queue.ts` ### Done when - [ ] An oversize file is a recorded failure, not a stop - [ ] A corrupt file does not halt the queue - [ ] Restarting resumes rather than rebuilding - [ ] The pixel limit is configured and documented ### Watch out - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. ## Phase 3 · Galleries and proofing that remember who chose A client gallery is a selection served behind a token, and every choice is attributed and kept. ### Steps 1. Store tokens hashed, with an expiry set at creation The link will be forwarded; plan for that from the start. Files: `src/galleries/tokens.ts`, `tests/token.test.ts` 2. Attribute every selection to a name and a time The argument about what was chosen is the one you must win. Files: `src/galleries/selections.ts` 3. Count the bytes each gallery serves Delivery is the cost that surprises people. Files: `src/galleries/usage.ts` ### Done when - [ ] Expired links refuse clearly - [ ] Revoking a link is immediate and non-destructive - [ ] Selections record who and when - [ ] Bytes served are counted per gallery ### Watch out - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. ## Phase 4 · Selling a download honestly A digital sale delivers only after settlement, records its licence, and accounts for what a refund costs. ### Steps 1. Deliver after settlement, never on the return page The return page is a redirect, not a payment. Files: `src/orders/fulfil.ts`, `tests/settlement.test.ts` 2. Issue single-use download tokens and record their use A download link is a bearer credential. Files: `src/orders/downloads.ts` 3. Record the processing fee on the order So your accounting shows what a refund really cost. Files: `src/orders/fees.ts` ### Done when - [ ] No token exists before settlement - [ ] Download tokens are single use and recorded - [ ] Licence wording is stored and shown - [ ] Refunds revoke unused tokens and show the lost fee ### Watch out - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. ## Not in this build - Print sales and laboratory fulfilment. - A raw conversion engine of your own. - Hosted synchronisation between devices. - Automatic culling and retouching models. ## After v1, if you want it - A watermarked proofing size generated once per gallery rather than per request - Delivery through storage you already pay for, once the bandwidth ceiling starts being reached - A per-shoot report of selections and downloads, which is the only client-facing number worth showing ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, edits, galleries, selections and orders. Not the photographs. CATALOGUE_PATH=./data/catalogue.db # Required. Folders indexed in place. Opened read-only, never written. LIBRARY_ROOTS=/mnt/photos/2026,/mnt/photos/2025 # Required. Derivatives keyed by hash and width. Deletable at the cost of a long rebuild. PREVIEW_CACHE=./data/previews # Required. The decoder's input limit, set deliberately. Larger files become recorded failures. MAX_INPUT_PIXELS=268402689 # Required. Default expiry set when a gallery link is created. GALLERY_LINK_DAYS=90 # Required. Public gallery serving stops here. The local catalogue keeps working. MONTHLY_BYTES_CEILING=100000000000 # Required · secret. Server side only, for checkout sessions and refunds. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies settlement and refund events before anything is delivered. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted
You are building a lean indie version of Zenfolio. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Zenfolio · indie build A local photo catalogue and delivery tool whose hard problems are never writing to an original, building previews for tens of thousands of files without falling over on the large ones, and selling a download in a way that survives a refund. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Index files in place by path and content hash, never copy or move them | the originals are the irreplaceable thing and this tool is a visitor | | Previews | A derivative cache keyed by hash and size, built by a queue that records failures | one corrupt file in twelve thousand must not stop the import | | Edits | Adjustment parameters stored per asset and per version, applied at render time | non-destructive means the file on disk is untouched, not that you kept a backup | | Delivery | Galleries served behind an unguessable token with an expiry and a download policy | a client link gets forwarded, and that is the design assumption | | Payments | A hosted checkout, delivery only after settlement, licence text stored on the order | a refund costs you the processing fee, so do not deliver on optimism | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the catalogue, the preview queue and the gallery server. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An image processing library with a documented input pixel limit** · free - Why: Every preview and every rendered export goes through it, and its default limit and failure behaviour are what phase two is built around. - Get it: Install it for your runtime and read its constructor options before writing the queue. - Verify: A deliberately oversize image is refused with the limit named, and the queue records it as a failure - [ ] **A real photo library, or a copy of one, with at least a few thousand files** · free, plus disk for derivatives - Why: Every hard part here is a scale problem, and a folder of twenty test images will tell you nothing about the preview queue or the integrity scan. - Get it: Point the catalogue at a copy of a real shoot, including a panorama and at least one file you know is corrupt. - Verify: A full import and preview build completes and reports its failures - [ ] **A payment processor account with hosted checkout and webhooks** · a percentage plus a fixed fee per sale; the fee is not returned on a refund - Why: Selling downloads means settlement events, refunds and fees, and all three shape the delivery design. - Get it: Create the account, complete verification, and take the keys plus the webhook signing secret. - Verify: A test payment settles and your endpoint receives a signed event - [ ] **Where you are registered to charge tax, and what your refund policy says** · free to decide; possibly not free to comply with - Why: You are the merchant of record, and deciding this after the first international sale means deciding it retrospectively. - Get it: Check the registration thresholds for the places your buyers are, write your refund policy, and put both in the checkout. - Verify: The checkout shows tax where you charge it and the refund policy text is stored with each order ## Quick start ```sh npm run backup npm run restore -- --verify ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Print sales and laboratory fulfilment. - A raw conversion engine of your own. - Hosted synchronisation between devices. - Automatic culling and retouching models. - hosting, templates, proofing, commerce, print labs, and photographer support - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem If one of those is essential to you, that is the reason to keep paying for Zenfolio, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Zenfolio The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a photo catalogue that publishes client galleries and sells a download without ever touching your originals to replace Zenfolio. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first is the reason this project exists. The originals are never modified, never moved and never rewritten. The catalogue indexes files where they already sit, records a hash and stores every adjustment as parameters. Open originals read-only wherever the platform lets you, and write a test that hashes every original before a full import, preview build and edit session, and compares afterwards. If your renderer can overwrite the only copy of somebody's wedding, no other feature in this build matters. Second, taking money makes you the merchant of record. Refunding a sale does not return what the payment cost you: the processor's own documentation states that its processing fees from the original transaction are not returned. Work out whether you must register for sales tax or value added tax where your buyers are before your first sale, because a tax calculation service computes a number but does not make you registered anywhere. And a printed order cannot be un-printed, which is why prints are out of scope here: sell downloads, deliver only after the payment has settled, and put the licence wording into the order. ### Stack (fixed, do not substitute) - Catalogue: Index files in place by path and content hash, never copy or move them · the originals are the irreplaceable thing and this tool is a visitor - Previews: A derivative cache keyed by hash and size, built by a queue that records failures · one corrupt file in twelve thousand must not stop the import - Edits: Adjustment parameters stored per asset and per version, applied at render time · non-destructive means the file on disk is untouched, not that you kept a backup - Delivery: Galleries served behind an unguessable token with an expiry and a download policy · a client link gets forwarded, and that is the design assumption - Payments: A hosted checkout, delivery only after settlement, licence text stored on the order · a refund costs you the processing fee, so do not deliver on optimism ### Data model (create this before Phase 1) - assets(id, path, sha256, bytes, width, height, taken_at, orientation, camera, missing_since) · a path and a hash, and nothing that could be used to write to the file - previews(asset_id, width, format, path, built_at, failed_reason) · a failure is a recorded row, not a stopped import - edits(asset_id, version, params_json, created_at) · crop, exposure, contrast, white balance and saturation as numbers - galleries(id, name, client_name, token_hash, expires_at, download_policy, watermark, bytes_served) - gallery_assets(gallery_id, asset_id, position) and selections(id, gallery_id, asset_id, chosen_by, chosen_at, note) - orders(id, gallery_id, buyer_email, amount_minor, tax_minor, fee_minor, state, settled_at, delivered_at, licence_text) - downloads(order_id, asset_id, token_hash, expires_at, used_at, bytes) · one use, one record, so a forwarded link is visible - Nothing in this schema writes to an original. The only file paths stored for originals are read paths, and the test suite hashes them before and after every run. ### Phase 1 · A catalogue that cannot touch your photographs Build: Walk the chosen folders, record path, size, dimensions, capture time, orientation and a content hash, opening files read-only. Detect duplicates by hash. Detect moved files by hash and repair the path rather than losing the edits. Mark missing files rather than deleting their rows. Trap: A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. Done when: Hashing every original before and after a full import, preview build and edit session shows no change, run as a test over a fixture library. A file moved between folders is matched by hash and its edits follow it, tested. A missing file is marked missing and its rows and edits survive, tested. Duplicate files are identified by hash and shown together without either being deleted. Do not build yet: No previews, edits, galleries or selling yet. ### Phase 2 · Previews for twelve thousand files Build: Build previews at the sizes the interface uses, keyed on hash and width. Set the decoder's input pixel limit explicitly rather than accepting the default, since a stitched panorama or a large scan will exceed it. Record a failure per file and carry on. Resume an interrupted build where it stopped. Report progress and the failures at the end. Trap: Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. Done when: An import containing a file over the configured pixel limit records a failure with the reason and completes the rest, tested with a generated oversize file. A corrupt file produces a recorded failure rather than stopping the queue, tested. Interrupting the build and restarting resumes without rebuilding completed derivatives, tested. The configured pixel limit appears in the settings and in the README with its value. Do not build yet: No edits, galleries, proofing or selling yet. ### Phase 3 · Galleries and proofing that remember who chose Build: A gallery is an ordered list of assets, not a copy of them. Serve it at a link containing an unguessable token, stored hashed, with an expiry and a download policy. Clients mark favourites and leave notes, recorded with a name they enter and a timestamp. Show the photographer a selection list that can be exported and never silently changes. Trap: Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. Done when: A gallery link is a hashed token with an expiry, and an expired link returns a clear message rather than the photographs, tested. Revoking a link makes it stop working immediately while the gallery and its selections survive, tested. Every selection records who made it and when, and the list is exportable. Bytes served are counted per gallery and visible to the photographer. Do not build yet: No selling, backup or export yet. ### Phase 4 · Selling a download honestly Build: A hosted checkout for a download of one or more images, with the price and tax shown first. Grant a single-use download token only after the payment has settled. Store the licence wording with the order. Record the processing fee on the order so your accounting shows what a refund actually costs. Refunding revokes any unused download token. Trap: Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. Done when: No download token exists before the payment has settled, tested with a delayed settlement event. A download token is single use and its use is recorded with the byte count, tested by trying it twice. The licence wording is stored on the order and shown in the receipt. Refunding revokes any unused token and the order shows the processing fee that was not returned. Do not build yet: No backup, bandwidth ceiling or export yet. ### Phase 5 · Backups, ceilings and leaving Build: Back up the catalogue database on a schedule with a tested restore. Export edit parameters, gallery definitions, selections and orders as documented JSON alongside a manifest of original paths and hashes. Put a monthly bytes-served ceiling on public galleries with a clear message when reached. Run an integrity scan that verifies hashes and reports files that changed underneath you. Trap: Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare. Done when: A restore from backup reconnects to the original files by hash and reports anything it cannot find, tested by moving files before restoring. The integrity scan detects a file whose content changed since import and reports it without modifying anything, tested. The exported JSON is documented and reimports into an empty catalogue with the same edits and galleries, tested. Reaching the monthly bytes-served ceiling stops public gallery serving with a message and leaves the local catalogue working. Do not build yet: Nothing after this. ### Out of scope (and why) - Print sales and laboratory fulfilment, because a printed order cannot be reversed and the refund, the postage and the remake all come out of your money rather than the platform's. - A raw conversion engine of your own, which is years of work and is the one part where the paid tools are genuinely ahead. - Hosted synchronisation between devices, which turns a local catalogue into a service with somebody else's photographs on your disk. - Automatic culling and retouching models, which need hardware, a model you did not train and a claim about quality you cannot support. ### README must contain - That originals are opened read-only, never modified or moved, and that the test suite hashes them before and after every run to check it - That the decoder's input pixel limit is set deliberately, with the number stated, and what happens to a file that exceeds it - That gallery links are hashed tokens with an expiry, can be revoked, and that bytes served are counted per gallery - That a download is delivered only after the payment settles, that the licence wording is stored on the order, and that a refund does not return the processing fee - What the backup contains, how a restore reconnects to originals by hash, and what the integrity scan reports ===== AGENTS.md ===== # Agent instructions · Zenfolio indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Index files in place by path and content hash, never copy or move them, A derivative cache keyed by hash and size, built by a queue that records failures, Adjustment parameters stored per asset and per version, applied at render time, Galleries served behind an unguessable token with an expiry and a download policy, A hosted checkout, delivery only after settlement, licence text stored on the order. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. ===== BUILD_PLAN.md ===== # Build plan · Zenfolio A local photo catalogue and delivery tool whose hard problems are never writing to an original, building previews for tens of thousands of files without falling over on the large ones, and selling a download in a way that survives a refund. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A catalogue that cannot touch your photographs Importing indexes files in place, records hashes, and provably leaves every original byte identical. ### Steps 1. Open originals read-only and hash them The hash is both the identity and the proof. Files: `src/catalogue/import.ts`, `tests/originals-unchanged.test.ts` 2. Repair moved files by hash rather than reimporting Otherwise reorganising a folder loses a year of edits. Files: `src/catalogue/repair.ts` 3. Mark missing, never delete An unplugged drive is not a deletion. Files: `src/catalogue/scan.ts` ### Done when - [ ] Originals hash identically before and after everything - [ ] Moved files are matched by hash and keep their edits - [ ] Missing files are marked, not removed - [ ] Duplicates are grouped rather than deleted ### Watch out - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. ## Phase 2 · Previews for twelve thousand files Derivatives build through a queue that survives large files, corrupt files and interruption. ### Steps 1. Set the decoder pixel limit deliberately The default will refuse a panorama and the maximum will exhaust memory. Files: `src/previews/decode.ts`, `docs/limits.md` 2. Record failures per file and keep going Twelve thousand files will contain a few bad ones. Files: `src/previews/queue.ts`, `tests/queue-failure.test.ts` 3. Resume an interrupted build Preview builds take hours and machines restart. Files: `src/previews/queue.ts` ### Done when - [ ] An oversize file is a recorded failure, not a stop - [ ] A corrupt file does not halt the queue - [ ] Restarting resumes rather than rebuilding - [ ] The pixel limit is configured and documented ### Watch out - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. ## Phase 3 · Galleries and proofing that remember who chose A client gallery is a selection served behind a token, and every choice is attributed and kept. ### Steps 1. Store tokens hashed, with an expiry set at creation The link will be forwarded; plan for that from the start. Files: `src/galleries/tokens.ts`, `tests/token.test.ts` 2. Attribute every selection to a name and a time The argument about what was chosen is the one you must win. Files: `src/galleries/selections.ts` 3. Count the bytes each gallery serves Delivery is the cost that surprises people. Files: `src/galleries/usage.ts` ### Done when - [ ] Expired links refuse clearly - [ ] Revoking a link is immediate and non-destructive - [ ] Selections record who and when - [ ] Bytes served are counted per gallery ### Watch out - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. ## Phase 4 · Selling a download honestly A digital sale delivers only after settlement, records its licence, and accounts for what a refund costs. ### Steps 1. Deliver after settlement, never on the return page The return page is a redirect, not a payment. Files: `src/orders/fulfil.ts`, `tests/settlement.test.ts` 2. Issue single-use download tokens and record their use A download link is a bearer credential. Files: `src/orders/downloads.ts` 3. Record the processing fee on the order So your accounting shows what a refund really cost. Files: `src/orders/fees.ts` ### Done when - [ ] No token exists before settlement - [ ] Download tokens are single use and recorded - [ ] Licence wording is stored and shown - [ ] Refunds revoke unused tokens and show the lost fee ### Watch out - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. ## Not in this build - Print sales and laboratory fulfilment. - A raw conversion engine of your own. - Hosted synchronisation between devices. - Automatic culling and retouching models. ## After v1, if you want it - A watermarked proofing size generated once per gallery rather than per request - Delivery through storage you already pay for, once the bandwidth ceiling starts being reached - A per-shoot report of selections and downloads, which is the only client-facing number worth showing ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, edits, galleries, selections and orders. Not the photographs. CATALOGUE_PATH=./data/catalogue.db # Required. Folders indexed in place. Opened read-only, never written. LIBRARY_ROOTS=/mnt/photos/2026,/mnt/photos/2025 # Required. Derivatives keyed by hash and width. Deletable at the cost of a long rebuild. PREVIEW_CACHE=./data/previews # Required. The decoder's input limit, set deliberately. Larger files become recorded failures. MAX_INPUT_PIXELS=268402689 # Required. Default expiry set when a gallery link is created. GALLERY_LINK_DAYS=90 # Required. Public gallery serving stops here. The local catalogue keeps working. MONTHLY_BYTES_CEILING=100000000000 # Required · secret. Server side only, for checkout sessions and refunds. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies settlement and refund events before anything is delivered. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted
You are building a production product version of Zenfolio. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Zenfolio · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Zenfolio, publish a portfolio and client galleries with basic downloads. The hard boundary is hosting, templates, proofing, commerce, print labs, and photographer support, plus image pipeline quality, models, and workflow polish. ## Product outcome A catalogue that provably never writes to an original, builds previews for a real library without stopping on the awkward files, serves client galleries behind revocable links, and sells a download that survives a refund. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop computer - local photo library - sufficient disk space - optional GPU for AI features ## Explicit non-goals for v1 - Print sales and laboratory fulfilment. - A raw conversion engine of your own. - Hosted synchronisation between devices. - Automatic culling and retouching models. - hosting, templates, proofing, commerce, print labs, and photographer support - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem ## Success criteria - Originals are confirmed byte identical after a full import, preview build and edit session - An oversize file is confirmed to become a recorded failure rather than a stop - A revoked gallery link is confirmed to stop working immediately - No download token is confirmed to exist before settlement - A restore is confirmed to reconnect assets by hash and report what is missing ===== BRIEF.md ===== # Build brief · Zenfolio The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a photo catalogue that publishes client galleries and sells a download without ever touching your originals to replace Zenfolio. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first is the reason this project exists. The originals are never modified, never moved and never rewritten. The catalogue indexes files where they already sit, records a hash and stores every adjustment as parameters. Open originals read-only wherever the platform lets you, and write a test that hashes every original before a full import, preview build and edit session, and compares afterwards. If your renderer can overwrite the only copy of somebody's wedding, no other feature in this build matters. Second, taking money makes you the merchant of record. Refunding a sale does not return what the payment cost you: the processor's own documentation states that its processing fees from the original transaction are not returned. Work out whether you must register for sales tax or value added tax where your buyers are before your first sale, because a tax calculation service computes a number but does not make you registered anywhere. And a printed order cannot be un-printed, which is why prints are out of scope here: sell downloads, deliver only after the payment has settled, and put the licence wording into the order. ### Stack (fixed, do not substitute) - Catalogue: Index files in place by path and content hash, never copy or move them · the originals are the irreplaceable thing and this tool is a visitor - Previews: A derivative cache keyed by hash and size, built by a queue that records failures · one corrupt file in twelve thousand must not stop the import - Edits: Adjustment parameters stored per asset and per version, applied at render time · non-destructive means the file on disk is untouched, not that you kept a backup - Delivery: Galleries served behind an unguessable token with an expiry and a download policy · a client link gets forwarded, and that is the design assumption - Payments: A hosted checkout, delivery only after settlement, licence text stored on the order · a refund costs you the processing fee, so do not deliver on optimism ### Data model (create this before Phase 1) - assets(id, path, sha256, bytes, width, height, taken_at, orientation, camera, missing_since) · a path and a hash, and nothing that could be used to write to the file - previews(asset_id, width, format, path, built_at, failed_reason) · a failure is a recorded row, not a stopped import - edits(asset_id, version, params_json, created_at) · crop, exposure, contrast, white balance and saturation as numbers - galleries(id, name, client_name, token_hash, expires_at, download_policy, watermark, bytes_served) - gallery_assets(gallery_id, asset_id, position) and selections(id, gallery_id, asset_id, chosen_by, chosen_at, note) - orders(id, gallery_id, buyer_email, amount_minor, tax_minor, fee_minor, state, settled_at, delivered_at, licence_text) - downloads(order_id, asset_id, token_hash, expires_at, used_at, bytes) · one use, one record, so a forwarded link is visible - Nothing in this schema writes to an original. The only file paths stored for originals are read paths, and the test suite hashes them before and after every run. ### Phase 1 · A catalogue that cannot touch your photographs Build: Walk the chosen folders, record path, size, dimensions, capture time, orientation and a content hash, opening files read-only. Detect duplicates by hash. Detect moved files by hash and repair the path rather than losing the edits. Mark missing files rather than deleting their rows. Trap: A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. Done when: Hashing every original before and after a full import, preview build and edit session shows no change, run as a test over a fixture library. A file moved between folders is matched by hash and its edits follow it, tested. A missing file is marked missing and its rows and edits survive, tested. Duplicate files are identified by hash and shown together without either being deleted. Do not build yet: No previews, edits, galleries or selling yet. ### Phase 2 · Previews for twelve thousand files Build: Build previews at the sizes the interface uses, keyed on hash and width. Set the decoder's input pixel limit explicitly rather than accepting the default, since a stitched panorama or a large scan will exceed it. Record a failure per file and carry on. Resume an interrupted build where it stopped. Report progress and the failures at the end. Trap: Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. Done when: An import containing a file over the configured pixel limit records a failure with the reason and completes the rest, tested with a generated oversize file. A corrupt file produces a recorded failure rather than stopping the queue, tested. Interrupting the build and restarting resumes without rebuilding completed derivatives, tested. The configured pixel limit appears in the settings and in the README with its value. Do not build yet: No edits, galleries, proofing or selling yet. ### Phase 3 · Galleries and proofing that remember who chose Build: A gallery is an ordered list of assets, not a copy of them. Serve it at a link containing an unguessable token, stored hashed, with an expiry and a download policy. Clients mark favourites and leave notes, recorded with a name they enter and a timestamp. Show the photographer a selection list that can be exported and never silently changes. Trap: Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. Done when: A gallery link is a hashed token with an expiry, and an expired link returns a clear message rather than the photographs, tested. Revoking a link makes it stop working immediately while the gallery and its selections survive, tested. Every selection records who made it and when, and the list is exportable. Bytes served are counted per gallery and visible to the photographer. Do not build yet: No selling, backup or export yet. ### Phase 4 · Selling a download honestly Build: A hosted checkout for a download of one or more images, with the price and tax shown first. Grant a single-use download token only after the payment has settled. Store the licence wording with the order. Record the processing fee on the order so your accounting shows what a refund actually costs. Refunding revokes any unused download token. Trap: Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. Done when: No download token exists before the payment has settled, tested with a delayed settlement event. A download token is single use and its use is recorded with the byte count, tested by trying it twice. The licence wording is stored on the order and shown in the receipt. Refunding revokes any unused token and the order shows the processing fee that was not returned. Do not build yet: No backup, bandwidth ceiling or export yet. ### Phase 5 · Backups, ceilings and leaving Build: Back up the catalogue database on a schedule with a tested restore. Export edit parameters, gallery definitions, selections and orders as documented JSON alongside a manifest of original paths and hashes. Put a monthly bytes-served ceiling on public galleries with a clear message when reached. Run an integrity scan that verifies hashes and reports files that changed underneath you. Trap: Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare. Done when: A restore from backup reconnects to the original files by hash and reports anything it cannot find, tested by moving files before restoring. The integrity scan detects a file whose content changed since import and reports it without modifying anything, tested. The exported JSON is documented and reimports into an empty catalogue with the same edits and galleries, tested. Reaching the monthly bytes-served ceiling stops public gallery serving with a message and leaves the local catalogue working. Do not build yet: Nothing after this. ### Out of scope (and why) - Print sales and laboratory fulfilment, because a printed order cannot be reversed and the refund, the postage and the remake all come out of your money rather than the platform's. - A raw conversion engine of your own, which is years of work and is the one part where the paid tools are genuinely ahead. - Hosted synchronisation between devices, which turns a local catalogue into a service with somebody else's photographs on your disk. - Automatic culling and retouching models, which need hardware, a model you did not train and a claim about quality you cannot support. ### README must contain - That originals are opened read-only, never modified or moved, and that the test suite hashes them before and after every run to check it - That the decoder's input pixel limit is set deliberately, with the number stated, and what happens to a file that exceeds it - That gallery links are hashed tokens with an expiry, can be revoked, and that bytes served are counted per gallery - That a download is delivered only after the payment settles, that the licence wording is stored on the order, and that a refund does not return the processing fee - What the backup contains, how a restore reconnects to originals by hash, and what the integrity scan reports ===== ARCHITECTURE.md ===== # Architecture · Zenfolio ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Index files in place by path and content hash, never copy or move them | the originals are the irreplaceable thing and this tool is a visitor | | Previews | A derivative cache keyed by hash and size, built by a queue that records failures | one corrupt file in twelve thousand must not stop the import | | Edits | Adjustment parameters stored per asset and per version, applied at render time | non-destructive means the file on disk is untouched, not that you kept a backup | | Delivery | Galleries served behind an unguessable token with an expiry and a download policy | a client link gets forwarded, and that is the design assumption | | Payments | A hosted checkout, delivery only after settlement, licence text stored on the order | a refund costs you the processing fee, so do not deliver on optimism | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Catalogue | In-place indexing, hashes and repair. | Not swappable for anything that writes to originals. | | Previews | Derivative cache, pixel limit and failure records. | Any library, provided the limit is explicit and failures are recorded. | | Galleries | Hashed tokens, expiry and attributed selections. | Any server, provided links can be revoked. | | Orders | Settlement-gated delivery, licence and fees. | Any processor, provided delivery waits for settlement. | | Integrity | Backup, restore by hash and the scan. | Any schedule, provided the manifest travels with the backup. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `CATALOGUE_PATH` · required · Assets, edits, galleries, selections and orders. Not the photographs. - `LIBRARY_ROOTS` · required · Folders indexed in place. Opened read-only, never written. - `PREVIEW_CACHE` · required · Derivatives keyed by hash and width. Deletable at the cost of a long rebuild. - `MAX_INPUT_PIXELS` · required · The decoder's input limit, set deliberately. Larger files become recorded failures. - `GALLERY_LINK_DAYS` · required · Default expiry set when a gallery link is created. - `MONTHLY_BYTES_CEILING` · required · Public gallery serving stops here. The local catalogue keeps working. - `PROCESSOR_SECRET_KEY` · required, secret · Server side only, for checkout sessions and refunds. - `PROCESSOR_WEBHOOK_SECRET` · required, secret · Verifies settlement and refund events before anything is delivered. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Zenfolio product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Index files in place by path and content hash, never copy or move them, A derivative cache keyed by hash and size, built by a queue that records failures, Adjustment parameters stored per asset and per version, applied at render time, Galleries served behind an unguessable token with an expiry and a download policy, A hosted checkout, delivery only after settlement, licence text stored on the order. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. - Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare. ===== MILESTONES.md ===== # Delivery milestones · Zenfolio Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A catalogue that cannot touch your photographs Importing indexes files in place, records hashes, and provably leaves every original byte identical. ### Steps 1. Open originals read-only and hash them The hash is both the identity and the proof. Files: `src/catalogue/import.ts`, `tests/originals-unchanged.test.ts` 2. Repair moved files by hash rather than reimporting Otherwise reorganising a folder loses a year of edits. Files: `src/catalogue/repair.ts` 3. Mark missing, never delete An unplugged drive is not a deletion. Files: `src/catalogue/scan.ts` ### Done when - [ ] Originals hash identically before and after everything - [ ] Moved files are matched by hash and keep their edits - [ ] Missing files are marked, not removed - [ ] Duplicates are grouped rather than deleted ### Watch out - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. ## M2 · Previews for twelve thousand files Derivatives build through a queue that survives large files, corrupt files and interruption. ### Steps 1. Set the decoder pixel limit deliberately The default will refuse a panorama and the maximum will exhaust memory. Files: `src/previews/decode.ts`, `docs/limits.md` 2. Record failures per file and keep going Twelve thousand files will contain a few bad ones. Files: `src/previews/queue.ts`, `tests/queue-failure.test.ts` 3. Resume an interrupted build Preview builds take hours and machines restart. Files: `src/previews/queue.ts` ### Done when - [ ] An oversize file is a recorded failure, not a stop - [ ] A corrupt file does not halt the queue - [ ] Restarting resumes rather than rebuilding - [ ] The pixel limit is configured and documented ### Watch out - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. ## M3 · Galleries and proofing that remember who chose A client gallery is a selection served behind a token, and every choice is attributed and kept. ### Steps 1. Store tokens hashed, with an expiry set at creation The link will be forwarded; plan for that from the start. Files: `src/galleries/tokens.ts`, `tests/token.test.ts` 2. Attribute every selection to a name and a time The argument about what was chosen is the one you must win. Files: `src/galleries/selections.ts` 3. Count the bytes each gallery serves Delivery is the cost that surprises people. Files: `src/galleries/usage.ts` ### Done when - [ ] Expired links refuse clearly - [ ] Revoking a link is immediate and non-destructive - [ ] Selections record who and when - [ ] Bytes served are counted per gallery ### Watch out - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. ## M4 · Selling a download honestly A digital sale delivers only after settlement, records its licence, and accounts for what a refund costs. ### Steps 1. Deliver after settlement, never on the return page The return page is a redirect, not a payment. Files: `src/orders/fulfil.ts`, `tests/settlement.test.ts` 2. Issue single-use download tokens and record their use A download link is a bearer credential. Files: `src/orders/downloads.ts` 3. Record the processing fee on the order So your accounting shows what a refund really cost. Files: `src/orders/fees.ts` ### Done when - [ ] No token exists before settlement - [ ] Download tokens are single use and recorded - [ ] Licence wording is stored and shown - [ ] Refunds revoke unused tokens and show the lost fee ### Watch out - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. ## M5 · Backups, ceilings and leaving (production only) The catalogue can be restored, the bandwidth has a ceiling, and everything exports in a documented form. ### Steps 1. Back up the catalogue and test the restore by hash A restore that cannot find the photographs is not a restore. Files: `src/backup.ts`, `tests/restore.test.ts` ```sh npm run backup npm run restore -- --verify ``` 2. Scan for originals that changed underneath you Drives fail quietly and so do synchronisation tools. Files: `src/integrity/scan.ts` 3. Put a ceiling on bytes served Delivery bandwidth is the bill that arrives without warning. Files: `src/galleries/limits.ts` ### Done when - [ ] Restore reconnects by hash and reports what is missing - [ ] The integrity scan detects changed originals - [ ] Export reimports with the same edits and galleries - [ ] The bandwidth ceiling stops serving, not the catalogue ### Watch out - Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare. ===== OPERATIONS.md ===== # Operations · Zenfolio ## Backup The catalogue database and the manifest of original paths and hashes, together. The photographs themselves are backed up by whatever you already use for them, and the manifest is what proves the two still match. ## Restore Restore the catalogue, run the verifying restore, and confirm every asset reconnects by hash. Anything that does not is either moved or changed, and the report tells you which. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Preview failure counts by reason, bytes served per gallery against the ceiling, integrity scan differences, and orders sitting unsettled. A rising integrity difference count usually means a synchronisation tool is rewriting files, not that the disk is failing. ## Incident checklist Two incidents matter: an original that changed, and a download delivered for a payment that did not stand up. The hash manifest answers the first, and the settlement gate plus the single-use token records answer the second. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Originals are confirmed byte identical after a full import, preview build and edit session - [ ] An oversize file is confirmed to become a recorded failure rather than a stop - [ ] A revoked gallery link is confirmed to stop working immediately - [ ] No download token is confirmed to exist before settlement - [ ] A restore is confirmed to reconnect assets by hash and report what is missing ## Launch constraint Do not market omitted Zenfolio capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, edits, galleries, selections and orders. Not the photographs. CATALOGUE_PATH=./data/catalogue.db # Required. Folders indexed in place. Opened read-only, never written. LIBRARY_ROOTS=/mnt/photos/2026,/mnt/photos/2025 # Required. Derivatives keyed by hash and width. Deletable at the cost of a long rebuild. PREVIEW_CACHE=./data/previews # Required. The decoder's input limit, set deliberately. Larger files become recorded failures. MAX_INPUT_PIXELS=268402689 # Required. Default expiry set when a gallery link is created. GALLERY_LINK_DAYS=90 # Required. Public gallery serving stops here. The local catalogue keeps working. MONTHLY_BYTES_CEILING=100000000000 # Required · secret. Server side only, for checkout sessions and refunds. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies settlement and refund events before anything is delivered. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted
# Zenfolio · indie build A local photo catalogue and delivery tool whose hard problems are never writing to an original, building previews for tens of thousands of files without falling over on the large ones, and selling a download in a way that survives a refund. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Index files in place by path and content hash, never copy or move them | the originals are the irreplaceable thing and this tool is a visitor | | Previews | A derivative cache keyed by hash and size, built by a queue that records failures | one corrupt file in twelve thousand must not stop the import | | Edits | Adjustment parameters stored per asset and per version, applied at render time | non-destructive means the file on disk is untouched, not that you kept a backup | | Delivery | Galleries served behind an unguessable token with an expiry and a download policy | a client link gets forwarded, and that is the design assumption | | Payments | A hosted checkout, delivery only after settlement, licence text stored on the order | a refund costs you the processing fee, so do not deliver on optimism | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the catalogue, the preview queue and the gallery server. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An image processing library with a documented input pixel limit** · free - Why: Every preview and every rendered export goes through it, and its default limit and failure behaviour are what phase two is built around. - Get it: Install it for your runtime and read its constructor options before writing the queue. - Verify: A deliberately oversize image is refused with the limit named, and the queue records it as a failure - [ ] **A real photo library, or a copy of one, with at least a few thousand files** · free, plus disk for derivatives - Why: Every hard part here is a scale problem, and a folder of twenty test images will tell you nothing about the preview queue or the integrity scan. - Get it: Point the catalogue at a copy of a real shoot, including a panorama and at least one file you know is corrupt. - Verify: A full import and preview build completes and reports its failures - [ ] **A payment processor account with hosted checkout and webhooks** · a percentage plus a fixed fee per sale; the fee is not returned on a refund - Why: Selling downloads means settlement events, refunds and fees, and all three shape the delivery design. - Get it: Create the account, complete verification, and take the keys plus the webhook signing secret. - Verify: A test payment settles and your endpoint receives a signed event - [ ] **Where you are registered to charge tax, and what your refund policy says** · free to decide; possibly not free to comply with - Why: You are the merchant of record, and deciding this after the first international sale means deciding it retrospectively. - Get it: Check the registration thresholds for the places your buyers are, write your refund policy, and put both in the checkout. - Verify: The checkout shows tax where you charge it and the refund policy text is stored with each order ## Quick start ```sh npm run backup npm run restore -- --verify ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Print sales and laboratory fulfilment. - A raw conversion engine of your own. - Hosted synchronisation between devices. - Automatic culling and retouching models. - hosting, templates, proofing, commerce, print labs, and photographer support - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem If one of those is essential to you, that is the reason to keep paying for Zenfolio, and the README should say so rather than pretend.
# Build brief · Zenfolio The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a photo catalogue that publishes client galleries and sells a download without ever touching your originals to replace Zenfolio. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first is the reason this project exists. The originals are never modified, never moved and never rewritten. The catalogue indexes files where they already sit, records a hash and stores every adjustment as parameters. Open originals read-only wherever the platform lets you, and write a test that hashes every original before a full import, preview build and edit session, and compares afterwards. If your renderer can overwrite the only copy of somebody's wedding, no other feature in this build matters. Second, taking money makes you the merchant of record. Refunding a sale does not return what the payment cost you: the processor's own documentation states that its processing fees from the original transaction are not returned. Work out whether you must register for sales tax or value added tax where your buyers are before your first sale, because a tax calculation service computes a number but does not make you registered anywhere. And a printed order cannot be un-printed, which is why prints are out of scope here: sell downloads, deliver only after the payment has settled, and put the licence wording into the order. ### Stack (fixed, do not substitute) - Catalogue: Index files in place by path and content hash, never copy or move them · the originals are the irreplaceable thing and this tool is a visitor - Previews: A derivative cache keyed by hash and size, built by a queue that records failures · one corrupt file in twelve thousand must not stop the import - Edits: Adjustment parameters stored per asset and per version, applied at render time · non-destructive means the file on disk is untouched, not that you kept a backup - Delivery: Galleries served behind an unguessable token with an expiry and a download policy · a client link gets forwarded, and that is the design assumption - Payments: A hosted checkout, delivery only after settlement, licence text stored on the order · a refund costs you the processing fee, so do not deliver on optimism ### Data model (create this before Phase 1) - assets(id, path, sha256, bytes, width, height, taken_at, orientation, camera, missing_since) · a path and a hash, and nothing that could be used to write to the file - previews(asset_id, width, format, path, built_at, failed_reason) · a failure is a recorded row, not a stopped import - edits(asset_id, version, params_json, created_at) · crop, exposure, contrast, white balance and saturation as numbers - galleries(id, name, client_name, token_hash, expires_at, download_policy, watermark, bytes_served) - gallery_assets(gallery_id, asset_id, position) and selections(id, gallery_id, asset_id, chosen_by, chosen_at, note) - orders(id, gallery_id, buyer_email, amount_minor, tax_minor, fee_minor, state, settled_at, delivered_at, licence_text) - downloads(order_id, asset_id, token_hash, expires_at, used_at, bytes) · one use, one record, so a forwarded link is visible - Nothing in this schema writes to an original. The only file paths stored for originals are read paths, and the test suite hashes them before and after every run. ### Phase 1 · A catalogue that cannot touch your photographs Build: Walk the chosen folders, record path, size, dimensions, capture time, orientation and a content hash, opening files read-only. Detect duplicates by hash. Detect moved files by hash and repair the path rather than losing the edits. Mark missing files rather than deleting their rows. Trap: A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. Done when: Hashing every original before and after a full import, preview build and edit session shows no change, run as a test over a fixture library. A file moved between folders is matched by hash and its edits follow it, tested. A missing file is marked missing and its rows and edits survive, tested. Duplicate files are identified by hash and shown together without either being deleted. Do not build yet: No previews, edits, galleries or selling yet. ### Phase 2 · Previews for twelve thousand files Build: Build previews at the sizes the interface uses, keyed on hash and width. Set the decoder's input pixel limit explicitly rather than accepting the default, since a stitched panorama or a large scan will exceed it. Record a failure per file and carry on. Resume an interrupted build where it stopped. Report progress and the failures at the end. Trap: Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. Done when: An import containing a file over the configured pixel limit records a failure with the reason and completes the rest, tested with a generated oversize file. A corrupt file produces a recorded failure rather than stopping the queue, tested. Interrupting the build and restarting resumes without rebuilding completed derivatives, tested. The configured pixel limit appears in the settings and in the README with its value. Do not build yet: No edits, galleries, proofing or selling yet. ### Phase 3 · Galleries and proofing that remember who chose Build: A gallery is an ordered list of assets, not a copy of them. Serve it at a link containing an unguessable token, stored hashed, with an expiry and a download policy. Clients mark favourites and leave notes, recorded with a name they enter and a timestamp. Show the photographer a selection list that can be exported and never silently changes. Trap: Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. Done when: A gallery link is a hashed token with an expiry, and an expired link returns a clear message rather than the photographs, tested. Revoking a link makes it stop working immediately while the gallery and its selections survive, tested. Every selection records who made it and when, and the list is exportable. Bytes served are counted per gallery and visible to the photographer. Do not build yet: No selling, backup or export yet. ### Phase 4 · Selling a download honestly Build: A hosted checkout for a download of one or more images, with the price and tax shown first. Grant a single-use download token only after the payment has settled. Store the licence wording with the order. Record the processing fee on the order so your accounting shows what a refund actually costs. Refunding revokes any unused download token. Trap: Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. Done when: No download token exists before the payment has settled, tested with a delayed settlement event. A download token is single use and its use is recorded with the byte count, tested by trying it twice. The licence wording is stored on the order and shown in the receipt. Refunding revokes any unused token and the order shows the processing fee that was not returned. Do not build yet: No backup, bandwidth ceiling or export yet. ### Phase 5 · Backups, ceilings and leaving Build: Back up the catalogue database on a schedule with a tested restore. Export edit parameters, gallery definitions, selections and orders as documented JSON alongside a manifest of original paths and hashes. Put a monthly bytes-served ceiling on public galleries with a clear message when reached. Run an integrity scan that verifies hashes and reports files that changed underneath you. Trap: Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare. Done when: A restore from backup reconnects to the original files by hash and reports anything it cannot find, tested by moving files before restoring. The integrity scan detects a file whose content changed since import and reports it without modifying anything, tested. The exported JSON is documented and reimports into an empty catalogue with the same edits and galleries, tested. Reaching the monthly bytes-served ceiling stops public gallery serving with a message and leaves the local catalogue working. Do not build yet: Nothing after this. ### Out of scope (and why) - Print sales and laboratory fulfilment, because a printed order cannot be reversed and the refund, the postage and the remake all come out of your money rather than the platform's. - A raw conversion engine of your own, which is years of work and is the one part where the paid tools are genuinely ahead. - Hosted synchronisation between devices, which turns a local catalogue into a service with somebody else's photographs on your disk. - Automatic culling and retouching models, which need hardware, a model you did not train and a claim about quality you cannot support. ### README must contain - That originals are opened read-only, never modified or moved, and that the test suite hashes them before and after every run to check it - That the decoder's input pixel limit is set deliberately, with the number stated, and what happens to a file that exceeds it - That gallery links are hashed tokens with an expiry, can be revoked, and that bytes served are counted per gallery - That a download is delivered only after the payment settles, that the licence wording is stored on the order, and that a refund does not return the processing fee - What the backup contains, how a restore reconnects to originals by hash, and what the integrity scan reports
# Agent instructions · Zenfolio indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Index files in place by path and content hash, never copy or move them, A derivative cache keyed by hash and size, built by a queue that records failures, Adjustment parameters stored per asset and per version, applied at render time, Galleries served behind an unguessable token with an expiry and a download policy, A hosted checkout, delivery only after settlement, licence text stored on the order. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund.
# Build plan · Zenfolio A local photo catalogue and delivery tool whose hard problems are never writing to an original, building previews for tens of thousands of files without falling over on the large ones, and selling a download in a way that survives a refund. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A catalogue that cannot touch your photographs Importing indexes files in place, records hashes, and provably leaves every original byte identical. ### Steps 1. Open originals read-only and hash them The hash is both the identity and the proof. Files: `src/catalogue/import.ts`, `tests/originals-unchanged.test.ts` 2. Repair moved files by hash rather than reimporting Otherwise reorganising a folder loses a year of edits. Files: `src/catalogue/repair.ts` 3. Mark missing, never delete An unplugged drive is not a deletion. Files: `src/catalogue/scan.ts` ### Done when - [ ] Originals hash identically before and after everything - [ ] Moved files are matched by hash and keep their edits - [ ] Missing files are marked, not removed - [ ] Duplicates are grouped rather than deleted ### Watch out - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. ## Phase 2 · Previews for twelve thousand files Derivatives build through a queue that survives large files, corrupt files and interruption. ### Steps 1. Set the decoder pixel limit deliberately The default will refuse a panorama and the maximum will exhaust memory. Files: `src/previews/decode.ts`, `docs/limits.md` 2. Record failures per file and keep going Twelve thousand files will contain a few bad ones. Files: `src/previews/queue.ts`, `tests/queue-failure.test.ts` 3. Resume an interrupted build Preview builds take hours and machines restart. Files: `src/previews/queue.ts` ### Done when - [ ] An oversize file is a recorded failure, not a stop - [ ] A corrupt file does not halt the queue - [ ] Restarting resumes rather than rebuilding - [ ] The pixel limit is configured and documented ### Watch out - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. ## Phase 3 · Galleries and proofing that remember who chose A client gallery is a selection served behind a token, and every choice is attributed and kept. ### Steps 1. Store tokens hashed, with an expiry set at creation The link will be forwarded; plan for that from the start. Files: `src/galleries/tokens.ts`, `tests/token.test.ts` 2. Attribute every selection to a name and a time The argument about what was chosen is the one you must win. Files: `src/galleries/selections.ts` 3. Count the bytes each gallery serves Delivery is the cost that surprises people. Files: `src/galleries/usage.ts` ### Done when - [ ] Expired links refuse clearly - [ ] Revoking a link is immediate and non-destructive - [ ] Selections record who and when - [ ] Bytes served are counted per gallery ### Watch out - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. ## Phase 4 · Selling a download honestly A digital sale delivers only after settlement, records its licence, and accounts for what a refund costs. ### Steps 1. Deliver after settlement, never on the return page The return page is a redirect, not a payment. Files: `src/orders/fulfil.ts`, `tests/settlement.test.ts` 2. Issue single-use download tokens and record their use A download link is a bearer credential. Files: `src/orders/downloads.ts` 3. Record the processing fee on the order So your accounting shows what a refund really cost. Files: `src/orders/fees.ts` ### Done when - [ ] No token exists before settlement - [ ] Download tokens are single use and recorded - [ ] Licence wording is stored and shown - [ ] Refunds revoke unused tokens and show the lost fee ### Watch out - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. ## Not in this build - Print sales and laboratory fulfilment. - A raw conversion engine of your own. - Hosted synchronisation between devices. - Automatic culling and retouching models. ## After v1, if you want it - A watermarked proofing size generated once per gallery rather than per request - Delivery through storage you already pay for, once the bandwidth ceiling starts being reached - A per-shoot report of selections and downloads, which is the only client-facing number worth showing
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, edits, galleries, selections and orders. Not the photographs. CATALOGUE_PATH=./data/catalogue.db # Required. Folders indexed in place. Opened read-only, never written. LIBRARY_ROOTS=/mnt/photos/2026,/mnt/photos/2025 # Required. Derivatives keyed by hash and width. Deletable at the cost of a long rebuild. PREVIEW_CACHE=./data/previews # Required. The decoder's input limit, set deliberately. Larger files become recorded failures. MAX_INPUT_PIXELS=268402689 # Required. Default expiry set when a gallery link is created. GALLERY_LINK_DAYS=90 # Required. Public gallery serving stops here. The local catalogue keeps working. MONTHLY_BYTES_CEILING=100000000000 # Required · secret. Server side only, for checkout sessions and refunds. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies settlement and refund events before anything is delivered. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted
# Zenfolio · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Zenfolio, publish a portfolio and client galleries with basic downloads. The hard boundary is hosting, templates, proofing, commerce, print labs, and photographer support, plus image pipeline quality, models, and workflow polish. ## Product outcome A catalogue that provably never writes to an original, builds previews for a real library without stopping on the awkward files, serves client galleries behind revocable links, and sells a download that survives a refund. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop computer - local photo library - sufficient disk space - optional GPU for AI features ## Explicit non-goals for v1 - Print sales and laboratory fulfilment. - A raw conversion engine of your own. - Hosted synchronisation between devices. - Automatic culling and retouching models. - hosting, templates, proofing, commerce, print labs, and photographer support - proprietary raw-processing quality - cloud sync and sharing - large AI models - camera and print ecosystem ## Success criteria - Originals are confirmed byte identical after a full import, preview build and edit session - An oversize file is confirmed to become a recorded failure rather than a stop - A revoked gallery link is confirmed to stop working immediately - No download token is confirmed to exist before settlement - A restore is confirmed to reconnect assets by hash and report what is missing
# Build brief · Zenfolio The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a photo catalogue that publishes client galleries and sells a download without ever touching your originals to replace Zenfolio. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first is the reason this project exists. The originals are never modified, never moved and never rewritten. The catalogue indexes files where they already sit, records a hash and stores every adjustment as parameters. Open originals read-only wherever the platform lets you, and write a test that hashes every original before a full import, preview build and edit session, and compares afterwards. If your renderer can overwrite the only copy of somebody's wedding, no other feature in this build matters. Second, taking money makes you the merchant of record. Refunding a sale does not return what the payment cost you: the processor's own documentation states that its processing fees from the original transaction are not returned. Work out whether you must register for sales tax or value added tax where your buyers are before your first sale, because a tax calculation service computes a number but does not make you registered anywhere. And a printed order cannot be un-printed, which is why prints are out of scope here: sell downloads, deliver only after the payment has settled, and put the licence wording into the order. ### Stack (fixed, do not substitute) - Catalogue: Index files in place by path and content hash, never copy or move them · the originals are the irreplaceable thing and this tool is a visitor - Previews: A derivative cache keyed by hash and size, built by a queue that records failures · one corrupt file in twelve thousand must not stop the import - Edits: Adjustment parameters stored per asset and per version, applied at render time · non-destructive means the file on disk is untouched, not that you kept a backup - Delivery: Galleries served behind an unguessable token with an expiry and a download policy · a client link gets forwarded, and that is the design assumption - Payments: A hosted checkout, delivery only after settlement, licence text stored on the order · a refund costs you the processing fee, so do not deliver on optimism ### Data model (create this before Phase 1) - assets(id, path, sha256, bytes, width, height, taken_at, orientation, camera, missing_since) · a path and a hash, and nothing that could be used to write to the file - previews(asset_id, width, format, path, built_at, failed_reason) · a failure is a recorded row, not a stopped import - edits(asset_id, version, params_json, created_at) · crop, exposure, contrast, white balance and saturation as numbers - galleries(id, name, client_name, token_hash, expires_at, download_policy, watermark, bytes_served) - gallery_assets(gallery_id, asset_id, position) and selections(id, gallery_id, asset_id, chosen_by, chosen_at, note) - orders(id, gallery_id, buyer_email, amount_minor, tax_minor, fee_minor, state, settled_at, delivered_at, licence_text) - downloads(order_id, asset_id, token_hash, expires_at, used_at, bytes) · one use, one record, so a forwarded link is visible - Nothing in this schema writes to an original. The only file paths stored for originals are read paths, and the test suite hashes them before and after every run. ### Phase 1 · A catalogue that cannot touch your photographs Build: Walk the chosen folders, record path, size, dimensions, capture time, orientation and a content hash, opening files read-only. Detect duplicates by hash. Detect moved files by hash and repair the path rather than losing the edits. Mark missing files rather than deleting their rows. Trap: A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. Done when: Hashing every original before and after a full import, preview build and edit session shows no change, run as a test over a fixture library. A file moved between folders is matched by hash and its edits follow it, tested. A missing file is marked missing and its rows and edits survive, tested. Duplicate files are identified by hash and shown together without either being deleted. Do not build yet: No previews, edits, galleries or selling yet. ### Phase 2 · Previews for twelve thousand files Build: Build previews at the sizes the interface uses, keyed on hash and width. Set the decoder's input pixel limit explicitly rather than accepting the default, since a stitched panorama or a large scan will exceed it. Record a failure per file and carry on. Resume an interrupted build where it stopped. Report progress and the failures at the end. Trap: Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. Done when: An import containing a file over the configured pixel limit records a failure with the reason and completes the rest, tested with a generated oversize file. A corrupt file produces a recorded failure rather than stopping the queue, tested. Interrupting the build and restarting resumes without rebuilding completed derivatives, tested. The configured pixel limit appears in the settings and in the README with its value. Do not build yet: No edits, galleries, proofing or selling yet. ### Phase 3 · Galleries and proofing that remember who chose Build: A gallery is an ordered list of assets, not a copy of them. Serve it at a link containing an unguessable token, stored hashed, with an expiry and a download policy. Clients mark favourites and leave notes, recorded with a name they enter and a timestamp. Show the photographer a selection list that can be exported and never silently changes. Trap: Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. Done when: A gallery link is a hashed token with an expiry, and an expired link returns a clear message rather than the photographs, tested. Revoking a link makes it stop working immediately while the gallery and its selections survive, tested. Every selection records who made it and when, and the list is exportable. Bytes served are counted per gallery and visible to the photographer. Do not build yet: No selling, backup or export yet. ### Phase 4 · Selling a download honestly Build: A hosted checkout for a download of one or more images, with the price and tax shown first. Grant a single-use download token only after the payment has settled. Store the licence wording with the order. Record the processing fee on the order so your accounting shows what a refund actually costs. Refunding revokes any unused download token. Trap: Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. Done when: No download token exists before the payment has settled, tested with a delayed settlement event. A download token is single use and its use is recorded with the byte count, tested by trying it twice. The licence wording is stored on the order and shown in the receipt. Refunding revokes any unused token and the order shows the processing fee that was not returned. Do not build yet: No backup, bandwidth ceiling or export yet. ### Phase 5 · Backups, ceilings and leaving Build: Back up the catalogue database on a schedule with a tested restore. Export edit parameters, gallery definitions, selections and orders as documented JSON alongside a manifest of original paths and hashes. Put a monthly bytes-served ceiling on public galleries with a clear message when reached. Run an integrity scan that verifies hashes and reports files that changed underneath you. Trap: Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare. Done when: A restore from backup reconnects to the original files by hash and reports anything it cannot find, tested by moving files before restoring. The integrity scan detects a file whose content changed since import and reports it without modifying anything, tested. The exported JSON is documented and reimports into an empty catalogue with the same edits and galleries, tested. Reaching the monthly bytes-served ceiling stops public gallery serving with a message and leaves the local catalogue working. Do not build yet: Nothing after this. ### Out of scope (and why) - Print sales and laboratory fulfilment, because a printed order cannot be reversed and the refund, the postage and the remake all come out of your money rather than the platform's. - A raw conversion engine of your own, which is years of work and is the one part where the paid tools are genuinely ahead. - Hosted synchronisation between devices, which turns a local catalogue into a service with somebody else's photographs on your disk. - Automatic culling and retouching models, which need hardware, a model you did not train and a claim about quality you cannot support. ### README must contain - That originals are opened read-only, never modified or moved, and that the test suite hashes them before and after every run to check it - That the decoder's input pixel limit is set deliberately, with the number stated, and what happens to a file that exceeds it - That gallery links are hashed tokens with an expiry, can be revoked, and that bytes served are counted per gallery - That a download is delivered only after the payment settles, that the licence wording is stored on the order, and that a refund does not return the processing fee - What the backup contains, how a restore reconnects to originals by hash, and what the integrity scan reports
# Architecture · Zenfolio ## Stack | Part | Choice | Why | | --- | --- | --- | | Catalogue | Index files in place by path and content hash, never copy or move them | the originals are the irreplaceable thing and this tool is a visitor | | Previews | A derivative cache keyed by hash and size, built by a queue that records failures | one corrupt file in twelve thousand must not stop the import | | Edits | Adjustment parameters stored per asset and per version, applied at render time | non-destructive means the file on disk is untouched, not that you kept a backup | | Delivery | Galleries served behind an unguessable token with an expiry and a download policy | a client link gets forwarded, and that is the design assumption | | Payments | A hosted checkout, delivery only after settlement, licence text stored on the order | a refund costs you the processing fee, so do not deliver on optimism | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Catalogue | In-place indexing, hashes and repair. | Not swappable for anything that writes to originals. | | Previews | Derivative cache, pixel limit and failure records. | Any library, provided the limit is explicit and failures are recorded. | | Galleries | Hashed tokens, expiry and attributed selections. | Any server, provided links can be revoked. | | Orders | Settlement-gated delivery, licence and fees. | Any processor, provided delivery waits for settlement. | | Integrity | Backup, restore by hash and the scan. | Any schedule, provided the manifest travels with the backup. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `CATALOGUE_PATH` · required · Assets, edits, galleries, selections and orders. Not the photographs. - `LIBRARY_ROOTS` · required · Folders indexed in place. Opened read-only, never written. - `PREVIEW_CACHE` · required · Derivatives keyed by hash and width. Deletable at the cost of a long rebuild. - `MAX_INPUT_PIXELS` · required · The decoder's input limit, set deliberately. Larger files become recorded failures. - `GALLERY_LINK_DAYS` · required · Default expiry set when a gallery link is created. - `MONTHLY_BYTES_CEILING` · required · Public gallery serving stops here. The local catalogue keeps working. - `PROCESSOR_SECRET_KEY` · required, secret · Server side only, for checkout sessions and refunds. - `PROCESSOR_WEBHOOK_SECRET` · required, secret · Verifies settlement and refund events before anything is delivered. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Zenfolio product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Index files in place by path and content hash, never copy or move them, A derivative cache keyed by hash and size, built by a queue that records failures, Adjustment parameters stored per asset and per version, applied at render time, Galleries served behind an unguessable token with an expiry and a download policy, A hosted checkout, delivery only after settlement, licence text stored on the order. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. - Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare.
# Delivery milestones · Zenfolio Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A catalogue that cannot touch your photographs Importing indexes files in place, records hashes, and provably leaves every original byte identical. ### Steps 1. Open originals read-only and hash them The hash is both the identity and the proof. Files: `src/catalogue/import.ts`, `tests/originals-unchanged.test.ts` 2. Repair moved files by hash rather than reimporting Otherwise reorganising a folder loses a year of edits. Files: `src/catalogue/repair.ts` 3. Mark missing, never delete An unplugged drive is not a deletion. Files: `src/catalogue/scan.ts` ### Done when - [ ] Originals hash identically before and after everything - [ ] Moved files are matched by hash and keep their edits - [ ] Missing files are marked, not removed - [ ] Duplicates are grouped rather than deleted ### Watch out - A catalogue that writes metadata back into the file, or rewrites orientation to save a rotation, changes the original, and the first person to notice is somebody restoring from a backup that no longer matches. Keep every change in your own database, open originals read-only, and hash before and after in the test suite so the guarantee is checked rather than promised. ## M2 · Previews for twelve thousand files Derivatives build through a queue that survives large files, corrupt files and interruption. ### Steps 1. Set the decoder pixel limit deliberately The default will refuse a panorama and the maximum will exhaust memory. Files: `src/previews/decode.ts`, `docs/limits.md` 2. Record failures per file and keep going Twelve thousand files will contain a few bad ones. Files: `src/previews/queue.ts`, `tests/queue-failure.test.ts` 3. Resume an interrupted build Preview builds take hours and machines restart. Files: `src/previews/queue.ts` ### Done when - [ ] An oversize file is a recorded failure, not a stop - [ ] A corrupt file does not halt the queue - [ ] Restarting resumes rather than rebuilding - [ ] The pixel limit is configured and documented ### Watch out - Image libraries refuse very large input by default, and one common library's documented default limit is 268402689 pixels, which a stitched panorama passes without being unusual at all. Accepting the default means the import stops or silently skips your best work; raising it without a limit means one malicious or broken file exhausts memory. Set the number deliberately, record it, and turn a refusal into a recorded failure rather than a crash. ## M3 · Galleries and proofing that remember who chose A client gallery is a selection served behind a token, and every choice is attributed and kept. ### Steps 1. Store tokens hashed, with an expiry set at creation The link will be forwarded; plan for that from the start. Files: `src/galleries/tokens.ts`, `tests/token.test.ts` 2. Attribute every selection to a name and a time The argument about what was chosen is the one you must win. Files: `src/galleries/selections.ts` 3. Count the bytes each gallery serves Delivery is the cost that surprises people. Files: `src/galleries/usage.ts` ### Done when - [ ] Expired links refuse clearly - [ ] Revoking a link is immediate and non-destructive - [ ] Selections record who and when - [ ] Bytes served are counted per gallery ### Watch out - Treating the gallery link as private means a forwarded link gives the whole wedding to anybody who receives it, with no expiry and no record. Store the token hashed, set an expiry at creation, count what each token has served, and let a link be revoked and reissued without rebuilding the gallery. ## M4 · Selling a download honestly A digital sale delivers only after settlement, records its licence, and accounts for what a refund costs. ### Steps 1. Deliver after settlement, never on the return page The return page is a redirect, not a payment. Files: `src/orders/fulfil.ts`, `tests/settlement.test.ts` 2. Issue single-use download tokens and record their use A download link is a bearer credential. Files: `src/orders/downloads.ts` 3. Record the processing fee on the order So your accounting shows what a refund really cost. Files: `src/orders/fees.ts` ### Done when - [ ] No token exists before settlement - [ ] Download tokens are single use and recorded - [ ] Licence wording is stored and shown - [ ] Refunds revoke unused tokens and show the lost fee ### Watch out - Delivering the file the moment the checkout page returns means a payment that later fails or is refunded has already handed over the full resolution image, and the processor's own documentation is clear that its processing fees from the original transaction are not returned, so you are out the file and the fee. Wait for settlement, issue a single-use token, and revoke unused tokens on refund. ## M5 · Backups, ceilings and leaving (production only) The catalogue can be restored, the bandwidth has a ceiling, and everything exports in a documented form. ### Steps 1. Back up the catalogue and test the restore by hash A restore that cannot find the photographs is not a restore. Files: `src/backup.ts`, `tests/restore.test.ts` ```sh npm run backup npm run restore -- --verify ``` 2. Scan for originals that changed underneath you Drives fail quietly and so do synchronisation tools. Files: `src/integrity/scan.ts` 3. Put a ceiling on bytes served Delivery bandwidth is the bill that arrives without warning. Files: `src/galleries/limits.ts` ### Done when - [ ] Restore reconnects by hash and reports what is missing - [ ] The integrity scan detects changed originals - [ ] Export reimports with the same edits and galleries - [ ] The bandwidth ceiling stops serving, not the catalogue ### Watch out - Backing up the catalogue while assuming the originals are somebody else's problem means a restore gives you edits pointing at files that are not there, and the integrity scan is the only thing that will tell you the drive quietly changed a file. Keep the manifest of paths and hashes with the backup, and run the scan on a schedule rather than after a scare.
# Operations · Zenfolio ## Backup The catalogue database and the manifest of original paths and hashes, together. The photographs themselves are backed up by whatever you already use for them, and the manifest is what proves the two still match. ## Restore Restore the catalogue, run the verifying restore, and confirm every asset reconnects by hash. Anything that does not is either moved or changed, and the report tells you which. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Preview failure counts by reason, bytes served per gallery against the ceiling, integrity scan differences, and orders sitting unsettled. A rising integrity difference count usually means a synchronisation tool is rewriting files, not that the disk is failing. ## Incident checklist Two incidents matter: an original that changed, and a download delivered for a payment that did not stand up. The hash manifest answers the first, and the settlement gate plus the single-use token records answer the second. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Originals are confirmed byte identical after a full import, preview build and edit session - [ ] An oversize file is confirmed to become a recorded failure rather than a stop - [ ] A revoked gallery link is confirmed to stop working immediately - [ ] No download token is confirmed to exist before settlement - [ ] A restore is confirmed to reconnect assets by hash and report what is missing ## Launch constraint Do not market omitted Zenfolio capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Assets, edits, galleries, selections and orders. Not the photographs. CATALOGUE_PATH=./data/catalogue.db # Required. Folders indexed in place. Opened read-only, never written. LIBRARY_ROOTS=/mnt/photos/2026,/mnt/photos/2025 # Required. Derivatives keyed by hash and width. Deletable at the cost of a long rebuild. PREVIEW_CACHE=./data/previews # Required. The decoder's input limit, set deliberately. Larger files become recorded failures. MAX_INPUT_PIXELS=268402689 # Required. Default expiry set when a gallery link is created. GALLERY_LINK_DAYS=90 # Required. Public gallery serving stops here. The local catalogue keeps working. MONTHLY_BYTES_CEILING=100000000000 # Required · secret. Server side only, for checkout sessions and refunds. PROCESSOR_SECRET_KEY=sk_live_...redacted # Required · secret. Verifies settlement and refund events before anything is delivered. PROCESSOR_WEBHOOK_SECRET=whsec_...redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Zenfolio because photographers pay because image quality, catalog safety, and fast handling of huge libraries matter more than cloning sliders. The recurring cost buys raw codecs, color management, metadata, previews, face models, GPU support, storage, backups, sync, and export fidelity, not just the visible interface.
xhosting, templates, proofing, commerce, print labs, and photographer support
xproprietary raw-processing quality
xcloud sync and sharing
xlarge AI models
xcamera and print ecosystem
Don't feel like building it? These folks already made it free.
PPhotoviewTurns owned folders into a clean web portfolio with RAW, EXIF, users, shares, and downloads; design customization is sparse.open source↗all 3 free alternatives to Zenfolio →· no votes, no pay-to-list · just what's real
Zenfolio pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| portfolio | $9/workspace | $7/workspace | 15 GB storage (about 4,500 photos) and 500 SMS messages per month. |
| portfolioplus | $23/workspace | $11.50/workspace | 150 GB storage (about 60,000 photos) and 2,500 SMS messages per month. |
| prosuite | $40/workspace | $20/workspace | Unlimited JPEG photo storage; 150 GB video (about 10 hours); 5,000 SMS messages per month; up to 4K video. |
free tierno free tier; a free trial is offered, but the current live page did not reliably expose its numeric duration
billingmonthly + annual; displayed annual rates are promotional first-year equivalents
hidden costsAI culling is $7/month on Portfolio and PortfolioPlus; Booking is $10/month on those tiers; RAW storage above 2 GB is $0.085/GB/month; commerce carries a 7% platform fee plus payment processing; terms also set 100 GB/month bandwidth and 25 GB/gallery limits.
verified 2026-08-14 · source ↗
Vibecode Zenfolio
Kinda. The core of Zenfolio is buildable in a weekend with the prompt on this page, but there are real gaps: hosting, templates, proofing, commerce, print labs, and photographer support, proprietary raw-processing quality. Read the honest list above before committing.
How much does Zenfolio cost?
Zenfolio costs about $9/month (Portfolio, checked 2026-07-31), which is $108 per year.
What do I lose by replacing Zenfolio?
Honestly: hosting, templates, proofing, commerce, print labs, and photographer support; proprietary raw-processing quality; cloud sync and sharing; large AI models; camera and print ecosystem. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Zenfolio?
Yes: Photoview (Turns owned folders into a clean web portfolio with RAW, EXIF, users, shares, and downloads; design customization is sparse.) Lychee (A tidy self-hosted portfolio and passworded client gallery with downloads; deliberately light on business machinery.) Piwigo (Portfolio-like themes, private client albums, users, metadata, and downloads on your server; sales and lab fulfillment do not come along.) All 3 curated free alternatives are at vibecodeit.com/zenfolio/alternatives. The prompt is for when you want it exactly your way.