stash2self

Changelog

What shipped, when. Newest first — versioned per SemVer using Common Changelog.

Last updated 2026-06-18

[2.4.0] - 2026-06-13

Feature release — the Phase 2 backlog lands across every surface. Bookmarks gain user tags, a reversible archive, and web editing; deletions now propagate to the iOS and PWA offline caches (they previously lingered forever); the iOS Share Extension queues sends made offline and replays them when the app next opens; and a new email-in channel turns a forwarded email into a bookmark. The wire contract changes are all additive — older clients keep working unchanged. This is the App Store submission build (TestFlight build 8). Test suite: 264 → 289 PHPUnit (822 assertions); StashKit 71 → 106; PWA Vitest 54 → 82; Chrome extension 21 → 31.

Added

  • User tags on every client: POST /api/ingest accepts tags[] (max 10, lowercase [a-z0-9-], ≤32 chars); list/item JSON carry tags. Web shows a filterable tag-chip rail + a tag editor on the item page; iOS shows filter chips (user tags styled distinctly from AI tags) on the native list; the Chrome popup and iOS Share sheet have tag-entry fields.
  • Archive: reversible hide-from-default-list (items.archived_at). Web per-row + item-page archive/unarchive; iOS swipe-to-archive (optimistic, rolls back on failure); archived items stay searchable with an "Archived" badge on web and iOS. New PATCH /api/item/{id}.json {archived}.
  • Edit bookmarks from the web: inline title/note/tag editing on the item page (PUT /item/{item}, owner-only via policy).
  • Deletion / tombstone sync: GET /api/list.json?since= now returns a deleted[] delta + meta.synced_at (server-authoritative cursor) + meta.full_resync_required; iOS SwiftData and PWA IndexedDB caches apply deletions and reconcile against the server on full sync (fixes forever-stale rows). Moderation-hidden items also tombstone.
  • iOS Share Extension offline queue: a failed send (no network / 5xx) is written to an App-Group outbox and replayed automatically when the app next foregrounds or runs its background refresh — paired with idempotency keys on /api/ingest so a replay never creates a duplicate bookmark or email.
  • Email-in: enable <alias>@in.stash2self.com on the settings page; a forwarded email's first link becomes a bookmark. Svix-verified Resend inbound webhook, sender allowlist, loop guard, per-user rate limit, free-tier cap, and webhook-retry dedupe. (Operator: verify the inbound domain + MX in Resend/cPanel and set RESEND_INBOUND_WEBHOOK_SECRET.)
  • Siri / Shortcuts: a "Stash this" App Intent accepts a URL and saves it through the same pipeline (queues offline on failure). Surfaces in Shortcuts and Spotlight; also reachable on macOS via Catalyst.
  • Per-row Resend retry: a "Retry" action on the web list/item page re-sends a failed email without creating a new bookmark (POST /item/{item}/resend, throttled).

Changed

  • ItemResource now exposes updated_at (clients previously could not detect edits); items source enum gains email_in and siri_shortcut.
  • CI gained an ios-build job (compiles the app + Share Extension, not just the StashKit package) and a chrome-ext-test job (the extension had no automated tests before).

[2.3.0] - 2026-06-13

Polish + onboarding release — the UX wave that precedes App Store submission. The iOS app gains a guided first-run experience (Get Started card with a direct path to QR pairing), a token-expiry recovery flow, an editable share-sheet title, and a working upgrade path from the Share Extension's paywall. The web app gains an Add-bookmark form (web users could previously not create bookmarks at all), iPhone setup instructions beside the token QR, and visible sync status. One canonical Stash2Self Pro pitch now appears on every paywall surface. Fixes a latent production bug where browser cookie-session requests to /api/* were never authenticated (Sanctum's stateful API middleware was missing), which silently broke the v2.2 PWA's background sync.

Added

  • iOS: Get Started onboarding card in the empty list state — numbered steps with a Safari link to account creation and a one-tap in-app QR scanner launch
  • iOS: stash2self://upgrade deep link presents the RevenueCat paywall; the Share Extension's free-tier 402 state now shows an "Upgrade in Stash2Self" button that opens it
  • iOS: token-expiry recovery — auth failures during sync (foreground or background) surface a "Token expired or revoked" banner with a Re-connect shortcut instead of a generic error
  • iOS: QR scanner shows explicit success ("Token imported") and invalid-code states instead of dismissing silently
  • iOS: list footer with bookmark count, "Last synced" time, and a warning when the last background sync failed
  • iOS: editable title field in the Share Extension (seeded with the fetched page title)
  • Web: Add bookmark modal on the list page (URL / title / note) wired to POST /api/ingest over the session — with free-tier 402 paywall handling inline
  • Web: "Set up your iPhone" instructions beside the new-token QR code
  • Web: loading skeleton on first paint and a live sync-status indicator (syncing / updated / offline) on the list page
  • Web: one-line descriptions under each AI settings toggle
  • StashKit: TokenImport utility centralizing deep-link parsing and bearer-token validation (16–256 chars) for both QR paths
  • StashKit: charset detection in the share-sheet title fetcher (Content-Type header, then <meta charset>) — non-Latin titles (Japanese, Chinese, Cyrillic) no longer garble
  • StashKit: hexadecimal HTML entity decoding in fetched titles (&#x2019; and friends)

Changed

  • iOS: Share Extension Send button is disabled when no URL was shared ("No link found in what you shared.")
  • iOS: app icon is now full-bleed — the white corners visible on the home screen are gone
  • iOS: search also matches bookmark URLs/hosts (parity with the web list), with a proper empty-results state
  • iOS: Config paste/scan buttons carry text labels; misconfigured share-sheet copy now points at the QR pairing flow
  • iOS: sync overlay no longer blocks the list while refreshing
  • Unified paywall copy across iOS detail view, Share Extension, web upgrade page, and API 402 responses: "Stash2Self Pro — unlimited bookmarks, AI tags & summaries, weekly digest, chat, and the Chrome extension. $2.99/month, cancel anytime."
  • Web header brand reads "Stash2Self" (was "Stash")
  • StashKit: HTTP 403 maps to an actionable "token missing required ability" message (was "Server error 403"); URLs pasted with embedded user:password@ credentials are stripped before use

Fixed

  • Browser cookie-session requests to /api/* now authenticate (statefulApi() enabled) — the v2.2 PWA's background sync silently ran as a guest in production, leaving the offline list stale
  • QR/deep-link token import now enforces an upper length bound (rejects oversized/garbage payloads)

[2.2.1] - 2026-06-10

Audit-cycle fix release across all four surfaces. The 2026-06-10 audit wave surfaced an incremental-sync boundary bug (items updated in the same second as the cursor were silently dropped), cross-user data residue on PWA logout (the IndexedDB cache survived sign-out on shared browsers), missing multi-tab sync serialization, token-endpoint hardening gaps, two critical Chrome extension defects (detached-popup tab capture, silent context-menu failures), and homepage canonical/OG metadata gaps. Chrome extension bumped to 1.6.0. No wire-contract changes. Test suite grows 250 → 253 PHPUnit (723 assertions) plus 6 new Vitest tests (39 total).

Changed

  • Throttle POST /settings/tokens at 10/min and cap personal access tokens at 25 per user (the 26th attempt flashes "Token limit reached — revoke an unused token first.")
  • Apply brand color tokens to the /admin/stats ApexCharts (#2F6B6B stash-accent / #4A8C5E stash-success / #B85C5C stash-error — replacing off-palette hex values)
  • Remove the dead "Subject shaping" and "Custom headers" sections from the Chrome extension options page (the server has ignored these fields since v2.1.3); legacy keys are silently trimmed from stored configs on load (Chrome ext 1.6.0)
  • Surface a non-blocking inline warning in the Chrome extension popup when the subscription-status check fails ("Subscription status unavailable — sends may fail.")
  • Emit full Open Graph + Twitter card meta on the homepage legal pages (privacy / terms / refunds / changelog) with per-page descriptions; refresh sitemap lastmod dates; bump homepage footer to v2.2.1

Fixed

  • Include items updated exactly at the ?since= cursor in incremental sync responses (updated_at >= since, was strict > — same-second updates were dropped; re-delivery is safe because all clients upsert idempotently)
  • Clear the IndexedDB stash-v1 database and the localStorage sync cursor on PWA logout (service-worker fetch intercept on POST /logout + page-side SyncManager.clearLocalState()) — previously the signed-out user's bookmarks remained readable in the browser's local store
  • Serialize the PWA sync entry points (boot / fullSync / incrementalSync) under a navigator.locks Web Lock named stash-sync so concurrent tabs can no longer interleave syncs (feature-detect fallthrough where Web Locks is absent)
  • Announce the app-layout flash/error toast to screen readers (role="alert" + aria-live="polite"); add visible focus rings to the nav settings / admin / theme-toggle / sign-out controls; label the chat prompt input
  • Capture the active tab of the last-focused window before opening the Chrome <127 detached keyboard-shortcut popup (pendingTabId via chrome.storage.session) — the fallback popup previously resolved the wrong tab
  • Set a persistent "!" toolbar badge on Chrome extension context-menu send failures (cleared on the next successful send or popup open) — failures were previously only a transient notification
  • Strip query strings from the homepage legal-page canonical URL and HTML-escape it (was a raw REQUEST_URI echo — UTM parameters leaked into the canonical)
  • Stop rendering escaped HTML markup in the changelog page's meta description (new $page_description layout variable)

[2.2.0] - 2026-05-30

Minor release: native iOS bookmark list + offline-first sync + PWA offline. The iOS app gains a SwiftUI bookmark list as its root screen, backed by a SwiftData local cache that's kept in sync via a new ?since= incremental sync endpoint on the backend. The Share Extension fires a lightweight sync after every save so new bookmarks appear in the list immediately. A BGAppRefreshTask keeps the cache current in the background. The PWA list page at app.stash2self.com/ is now an app shell backed by IndexedDB — loads and searches the full local history while offline. No backend breaking changes; the ?since= parameter is additive. Test suite grows from 247 → 250 (zero failures).

Added

  • Backend GET /api/list.json?since=ISO_DATE query param — returns only items where updated_at > since. Carbon::parse with try/catch; 422 on invalid timestamp. Covered by ListJsonSinceTest.
  • ai_summary field in ItemResource::toArray() (was on the model but absent from the JSON shape — clients couldn't read AI summaries).
  • StashKit BookmarkItem, ListResponse, BookmarkDetail Codable types. BookmarkDetail is a typealias for BookmarkItem (same ItemResource on both list and detail endpoints).
  • StashKit CachedBookmark SwiftData @Model entity. updatedAt set to syncedAt at init time (not decoded from JSON — updated_at is not in ItemResource).
  • StashKit SyncEngine actor with fullSync(client:context:), incrementalSync(client:context:), and syncAfterIngest(client:context:). ModelContext passed per-call (Swift Sendability — never stored on the actor). lastSyncedAt stored as Double (timeIntervalSince1970) in App Group UserDefaults so the Share Extension reads/writes the same cursor. maxPages=50 guards against runaway pagination.
  • StashKit StashClient.listItems(page:since:) and StashClient.getItem(id:). ISO8601DateFormatter with .withFractionalSeconds for the ?since= query param; JSONDecoder .iso8601 for response date decoding.
  • iOS BookmarkListView (native SwiftUI list with @Query over SwiftData, .searchable for offline search, pull-to-refresh, gear-icon Config sheet with onDismiss reload).
  • iOS BookmarkRowView (title 2-line truncated, host domain, up to 3 AI tag chips, relative timestamp).
  • iOS BookmarkDetailView (full detail with AI tags + AI summary gated by SubscriptionStore.status?.canUseAi; paywall nudge for free users).
  • iOS BookmarkListViewModel (@MainActor @Observable with isLoading, error, searchText, hasBearerToken).
  • iOS root swap: BookmarkListView replaces ConfigView as the app root. ConfigView is now presented as a gear-icon .sheet.
  • iOS BGAppRefreshTask registered with identifier com.stash2self.app.refresh for background incremental sync. Handler + scheduler are static methods to avoid self capture in BGTaskScheduler.shared.register closure.
  • Share Extension fires SyncEngine.syncAfterIngest() after a successful POST /api/ingest (fire-and-forget; never blocks the share flow). makeSyncContainer() uses try? since Share Extension processes are short-lived and App Group access should be graceful.
  • iOS ConfigView Done toolbar button (@Environment(\.dismiss)).
  • PWA resources/js/sync.jsSyncManager class with vanilla IndexedDB (stash-v1 DB, bookmarks store, created_at index ONLY — updated_at is not in ItemResource). boot(), fullSync(), incrementalSync(), upsertMany(), getAll(), deleteOne(). MAX_PAGES=50 cap. All fetches use credentials: 'include' for Sanctum cookie auth.
  • PWA resources/js/list-app.js — Alpine stashList component that reads from IndexedDB on init, runs background sync if online, filters filteredItems client-side across title/note/url. CSRF-token-protected delete via fetch DELETE.
  • PWA service worker VERSION bumped to v3 — activate handler sweeps the old v2 static + data caches on next SW activation, forcing fresh fetch of the new app-shell HTML.
  • QR bearer-token import (added 2026-06-08, commit 061d3fc): the web token page generates a client-side QR encoding stash2self://connect?token=…; iOS imports it via system-camera deep link (StashApp.onOpenURLKeychainStore) or the in-app VisionKit scanner (QRScannerView + ConfigViewModel.handleQRScan()). URL scheme + NSCameraUsageDescription registered in project.yml.

Changed

  • iOS ConfigView "View bookmarks" Safari button removed (redundant with the new native list).
  • PWA resources/views/items/index.blade.php converted from server-rendered @forelse pagination to an Alpine x-data="stashList" app shell with <template x-for> over filteredItems. The paywall affordance block at the top stays server-rendered. The search input is now fully client-side (x-model.debounce.300ms="q", no <form>). resend_status badges (email failed / pending) included via <template x-if>.
  • ItemsController::index is unchanged but the new app-shell template silently ignores $items and $q. Per-user data isolation is now enforced at the API layer (covered by ListJsonTest::test_returns_only_authenticated_users_items).
  • Tests\Feature\ItemsControllerTest::test_index_only_shows_authenticated_users_items and test_dashboard_alias_returns_items_index updated to assert the app-shell marker (x-data="stashList") instead of server-rendered item titles.
  • Bookmark list sort changed from latest() (created_at) to latest('updated_at') in ListJsonController for correct incremental-sync ordering. The first page of ?since= is therefore the items most recently AI-enriched (covers items updated by AutoTagItemJob and AutoSummaryItemJob after their original insert).

Fixed

  • ItemResource now exposes ai_summary (was on the model but missing from the JSON response — clients couldn't read AI summaries).

[2.1.5] - 2026-05-27

Audit-cycle fix release. The 2026-05-27 audit cycle surfaced security hardening gaps (session fixation on registration, MCP endpoint missing rate limit and JSON enforcement, GET /logout returning 405), data-hygiene gaps (GDPR export missing auth_attempts, user deletion not cleaning auth_attempts, admin_actions FK cascading on admin delete), and a set of correctness fixes across the AI surface, admin stats, MCP tools, Chrome extension, and CDN asset integrity. No regressions. Test suite stays green.

Fixed

  • Map RevenueCat REFUND webhook to subscription deactivation (was silently no-oping)
  • POST /mcp now returns 401 JSON for unauthenticated requests (was 302 redirect)
  • Add throttle:60,1 rate limit to MCP route group
  • AutoTagItemJob fetches page URL content (5s timeout, 256 KB cap) for AI prompts
  • Three new MCP tools: list_items, get_item, summarize_recent
  • GDPR export now includes auth_attempts records
  • User deletion now cleans up auth_attempts records
  • admin_actions.actor_id FK changed to nullOnDelete (preserves audit trail on admin deletion)
  • Admin user-delete form now requires a reason before confirming
  • Moderation search paginated (was hard-capped at 50 results)
  • RollupDailyStatsJob MRR derived from subscriptions event log, not users.updated_at proxy
  • ItemResource now includes ai_tags field
  • Session fixation fix: session regenerated after registration login
  • GET /logout redirects to / (was returning 405)
  • Chrome extension options: custom headers description corrected to server-side
  • ApexCharts and Alpine.js CDN scripts now include SRI hashes
  • Alpine @alpinejs/collapse plugin added to marketing homepage
  • App Store badge now links to apps.apple.com/app/id6765715262

[2.1.4] - 2026-05-18

Audit-cycle fix release. The 2026-05-18 triple-pass go-live audit surfaced four important code fixes (impersonation banner name, OAuth callback throttle, items_created chart gap, settings Pro upgrade links) plus a set of polish fixes across all five surfaces. No critical findings. Zero regressions on all prior audit fixes. Test suite stays at 230/653 / green.

Fixed

  • Impersonation banner shows actor's display name, not email. The red banner used session('impersonator_email') for the "You (…)" span. ImpersonateController now stores impersonator_name alongside impersonator_email; banner reads name with email fallback. (audit PLAYWRIGHT-BANNER-001)
  • items_created chart on /admin/stats now renders. RollupDailyStatsJob wrote per-source dimension rows only; the Blade chart read $series['items_created']['_total'], which was always empty. Job now also writes a _total aggregate row after the per-source loop. (audit ADMIN-TIERS-2026-05-18-011)
  • Settings Pro upgrade captions are now clickable links. The "Included with Stash Pro — upgrade at App Store" caption on all three AI-feature toggles was plain text for free users. Changed to <a href="{{ route('upgrade') }}">. (audit FRONTEND-2026-05-18-021)
  • OAuth callback route rate-limited. /auth/{provider}/callback was missing a throttle; now applies throttle:10,1. (audit C2-38)
  • IngestPayload.swift docstring corrected. Opening comment said "url and body are both optional — at least one must be present"; server validation requires url unconditionally. Docstring updated to match actual contract. (audit C3-12)
  • StashClient.swift docstring updated to three endpoints. "two endpoints" claim omitted GET /api/me/subscription added in K10. (audit STASHKIT-2026-05-18-019)
  • Sitemap /changelog entry now has <lastmod>. Added <lastmod>2026-05-12</lastmod> to the /changelog URL block. (audit HOMEPAGE-SITEMAP-002)
  • Chrome ext: dead headersToObject export removed. lib/config.js exported headersToObject but no caller remained after the v2.1.3 wire-contract trim. Export deleted. (audit CHROME-EXT-2026-05-18-015)
  • Admin tile label clarified. "Admin actions" counter tile (always shows the last-10 count, not a global total) relabelled "Last 10 admin actions". (audit ADMIN-OPS-2026-05-18-020)
  • Stale #pricing comment removed from items index. Block comment in items/index.blade.php referenced stash2self.com/#pricing; link has routed to /upgrade since v2.1.3. Comment trimmed. (audit ITEMS-WIRE-2026-05-18-017)

Added

  • Apple Sign in with Apple env vars in .env.example. APPLE_KEY_ID, APPLE_TEAM_ID, and APPLE_PRIVATE_KEY placeholder entries added. Required for dynamic JWT generation (alternative to the pre-generated APPLE_CLIENT_SECRET). (audit AUTH-2026-05-18-011)
  • Apple block in config/services.php exposes key/team/private-key. Added key_id, team_id, private_key config entries mapped to the new env vars. (audit AUTH-2026-05-18-011)

[2.1.3] - 2026-05-12

Audit-cycle polish release. The 2026-05-12 triple-pass go-live audit (.audit-2026-05-12/final-report.md) surfaced five UX/contract gaps (free-tier paywall routing, Chrome ext wire drift, Pro affordance on settings) and a set of marketing-homepage improvements (HSTS, real changelog page, custom 404). No critical findings. Test suite stays at 230/653 / green.

Fixed

  • Free-tier paywall card "Upgrade →" link now routes in-app. The paywall card on the items index sent users to https://stash2self.com/#pricing (marketing apex anchor) instead of the existing in-app /upgrade page. Changed to {{ route('upgrade') }} so the flow stays in-context. (audit walkthrough finding 2)
  • Chrome ext ingest payload drops fields the server ignores. subject_preface, subject_suffix, and extra_headers were sent on every ingest but the server reads these from user_settings exclusively and silently discards payload values. Aligns Chrome ext with the iOS v2.1.2 wire-contract fix (STASHKIT-002). Chrome ext manifest bumped to 1.5.2. (audit C3-8)
  • Chrome ext popup paywall reads server-supplied item counts. The 402 cap response includes item_count and item_limit; the popup view was ignoring them and displaying hardcoded "20 / 20". (audit walkthrough)
  • Chrome ext options page shows real extension version. Footer was hard-coded v1.5.0; now reads chrome.runtime.getManifest().version dynamically. (audit C3-8)

Added

  • Pro affordance on free-tier /settings AI toggles. All three AI feature toggles (auto-tag, auto-summarize, weekly digest) previously rendered identically for free and paid users with no visual hint that they are inert. Free users now see a "Pro" pill, disabled state, and a caption linking to /upgrade. Server-side gating unchanged. (audit walkthrough finding 3)
  • Custom 404 page on marketing homepage. /404.php added using the existing _legal_layout.php scaffold; .htaccess wired with ErrorDocument 404 /404.php. (audit HOMEPAGE-1)
  • Real CHANGELOG.md rendered on marketing /changelog. changelog.php replaced the "Coming soon" stub with a pure-PHP Markdown renderer reading _legal_pages/changelog.md. The deploy script now copies CHANGELOG.mdhomepage/_legal_pages/changelog.md on every homepage deploy. (audit HOMEPAGE-2)
  • MAIL_SUPPORT_ADDRESS config key. Added to config/mail.php and .env.example (support@stash2self.com). Prepares for a future support-inquiry contact form. (audit MAIL-002)

Changed

  • HSTS enabled on marketing homepage. Strict-Transport-Security: max-age=31536000; includeSubDomains uncommented in .htaccess (without preload — hstspreload.org submission is a separate operator step). (audit HOMEPAGE-3)
  • Marketing homepage footer version stamp. $config['version'] 2.0.0 → 2.1.1; $config['updated'] 2026-04 → 2026-05.
  • Marketing sitemap lastmod refreshed on apex URL to 2026-05-12.
  • Admin subscriptions heading renamed. "Recent webhook events" → "Recent subscription events" — more accurate since the card shows RevenueCat-triggered subscription lifecycle events, not raw webhook payloads.
  • Impersonation banner copy clarified. "Impersonating {target} as {actor}" → "You ({impersonator}) are acting as {target}" — active-voice framing that removes ambiguity about who is who.

Removed

  • Orphan manifest.webmanifest from marketing homepage. The marketing site is intentionally not a PWA; the file was an unreferenced leftover causing a dead asset. (audit HOMEPAGE-1)
  • Stale "Audit finding (2026-04-30)" blockquotes from CLAUDE_BACKEND.md and CLAUDE_SECURITY.md. The Sanctum-null warning and related stale notes were closed by the 2026-05-02 audit; the blockquotes were documentation debt.

[2.1.2] - 2026-05-02

Audit-cycle fix release. The 2026-05-02 triple-pass go-live audit (.audit-2026-05-02/final-report.md) closed two critical wire-contract / paywall-flow gaps and nine important fixes spanning security middleware, AI scope hygiene, marketing copy, and documentation truth-up. Test suite up from 227/644 → 230/653 / green.

Fixed

  • Wire contract — IngestController 502 partial-success now decodes on iOS. The 502 response (row inserted, Resend failed) emits the status field required by iOS IngestResponse, so StashError.resendFailed actually fires. Without it, the partial-success catch in ShareRootView and ConfigViewModel was dead code; users saw a generic server error on partial failure. Server-side fix only — no iOS rebuild needed. (audit STASHKIT-001 / C3-3)
  • Security — PUT /settings now gated by block.during.impersonation. Previously, an admin acting under impersonation could change the target user's destination_email to an attacker mailbox and exfiltrate every subsequent bookmark email. Token routes already had the middleware; settings did not. Regression test added in ImpersonationTest. (audit SETTINGS-TOKENS-1)
  • Security — user delete now cleans up personal_access_tokens. Sanctum's personal_access_tokens uses polymorphic morphs() with no FK and no deleting() observer, so a user deletion via UserDeleteController orphaned every issued token. Now explicitly calls $user->tokens()->delete() inside the transaction, with a regression test in UserDeleteTest. (audit ADMIN-OPS-1)
  • AI surface — chat + MCP + weekly digest now respect Item::scopeVisible. Moderator-hidden items previously leaked through chat-time SearchBookmarksTool, the MCP SearchBookmarksTool, and the SendWeeklyDigestJob thisWeek / olderSample / resurfaced queries — none of which chained ->visible(). ItemsController::index already excluded them; the AI surfaces now match. Regression test in StashMcpServerTest. (audit AI-001 / AI-002 / C3-5)
  • Admin stats — auth-attempts ApexChart now renders. RollupDailyStatsJob writes namespaced 'succeeded:true' / 'succeeded:false' dimension keys (matching the 'k:v' shape of every other dimension), but stats.blade.php was reading ['1'] / ['true'] / ['0'] / ['false']. View updated to read the actual keys with legacy fallback. (audit ADMIN-TIERS-1)
  • Chrome ext upgrade URL no longer 404s. The K11 popup paywall + mid-send 402 paywall fabricated ${base}/settings/subscription, a route that doesn't exist. New public /upgrade Blade route on the Laravel app surfaces a brand-on landing page with iOS upgrade guidance; Chrome ext fallback updated. Manifest version bumped to 1.5.1. (audit CHROME-1)
  • Chrome ext over-permissioning closed. manifest.json declared the scripting permission but chrome.scripting is never used. Removing it shrinks the install-prompt blast radius. (audit CHROME-2)
  • iOS wire-contract cleanup — IngestPayload no longer sends ignored fields. subject_preface / subject_suffix / extra_headers were sent on every ingest but the server sources them from user_settings exclusively, silently discarding payload values. Parallel to the v1.3.0 destination_email removal. Local share-sheet preview still uses StashConfig.subjectPreface/Suffix for the chip. (audit STASHKIT-002)

Changed

  • MAIL_FROM_NAME aligned to "Stash2Self". Locked-decision text in CLAUDE.md and CLAUDE_DEPLOYMENT.md was stale at "Stash" from before the rebrand; production .env.example already used "Stash2Self". (audit MAIL-001)
  • CLAUDE_*.md test counts truth-up. 103/246 → 227/644 across CLAUDE.md, CLAUDE_TESTING.md, CLAUDE_HISTORY.md. (audit INFRA-DOCS-3)
  • Deploy script doc — --no-interaction--compact. PHPUnit 12 rejects the Laravel-only --no-interaction flag; the actual deploy gate already uses --compact (audit-2026-05-01). CLAUDE_DEPLOYMENT.md + CLAUDE_TESTING.md updated to match. (audit INFRA-DOCS-1)
  • Prod env table truth-up. QUEUE_CONNECTION=databasesync to match the actual .env.example default flipped in audit-2026-05-01 INFRA-5. (audit INFRA-DOCS-2)
  • .env.example adds Phase 4 AI placeholders. AI_DEFAULT_PROVIDER + ANTHROPIC_API_KEY. (audit AI-006)
  • Items detail page renders friendly source label. ItemSource::label() helper added; items/show.blade.php reads it instead of the raw enum value. (audit ITEMS-1)
  • iOS ConfigView header — "Stash" → "Stash2Self". Brand consistency; bundle display name was already correct. (audit IOS-APP-1)
  • Marketing install subhead now lists GitHub. "Sign in once with Apple, Google, or email" → "Sign in once with Apple, Google, GitHub, or email" for parity with the Yours-alone card and the privacy policy. (audit MARKETING-002)
  • ChatController docblock truth-up. Removed misleading claim of client-side history persistence; chat is one-prompt-at-a-time. (audit AI-003)

Added

  • Public /upgrade Blade landing page for Chrome ext + iOS paywall fallback when stash://upgrade deep-link doesn't resolve.
  • Regression tests for every important security fix: ImpersonationTest::test_block_during_impersonation_refuses_settings_update, UserDeleteTest::test_user_delete_cascades_personal_access_tokens, StashMcpServerTest::test_search_excludes_moderator_hidden_items.

[2.1.1] - 2026-05-02

Operator-task knockdown release. The §2–§6 manual steps in docs/OPERATOR_TASKS.md — most of which were expected to take an hour of clicking through the Apple Developer portal, App Store Connect, and the RevenueCat dashboard — get driven via asc CLI + Playwright automation. The K10 iOS subscription store stub gets replaced by the real RevenueCat SDK. TestFlight build 1.0.0/1 ships to internal testers.

The locked $3/mo subscription product com.stash2self.app.pro.monthly is live in App Store Connect (Tier 3 / $2.99 USD equalized to all 175 territories, 39 locales), wired through RevenueCat to the Stash2Self Pro entitlement, and the App Store ↔ RevenueCat ↔ Stash backend webhook loop is closed end-to-end.

Added

  • iOS RevenueCat SDK integration (replaces the K10 stub). purchases-ios-spm v5.70.0 + RevenueCatUI added via SwiftPM in stash-ios/project.yml. StashApp.swift configures Purchases with the RevenueCat production public key, presents PaywallView from the default offering, and wires the CustomerCenterView for paid-user manage-subscription / cancel / refund flows. SubscriptionStore.swift is now a thin observable wrapper that stays server-authoritative for entitlement state (isSubscribed reads from the backend's users.subscription_active via GET /api/me/subscription) while delegating purchase / paywall UI / Customer Center to RevenueCat. After every server refresh, the store calls Purchases.logIn(userId) so RC's app_user_id matches what RevenueCatController::__invoke casts to (int) and User::finds.
  • /api/me/subscription exposes user_id. Stable string form of the backend's users.id primary key. Threaded through SubscriptionStatus.swift so the iOS RevenueCat logIn(userId) call has a value to use without a separate /api/me round-trip. Two MeSubscriptionTest cases updated; suite stays green.
  • SubscriptionRow view in ConfigView.swift. Free-tier card shows item-cap progress + "Upgrade to Pro" button (calls subscriptionStore.presentPaywall()); paid-tier shows "Stash2Self Pro" badge + "Manage" button (calls subscriptionStore.presentCustomerCenter()).
  • TestFlight build 1.0.0 (1) uploaded. Build id e44b00af-a110-4b1f-b0dc-42462f8e9565, internalBuildState: IN_BETA_TESTING, processingState: VALID. Distributed to a new Internal Testers group (id 3feb184e-…) with the Account Holder (17@randomsynergy.com) as the sole tester. Beta App Localization + Beta License Agreement filled — both were Apple-side gates that would otherwise hide the build from the TestFlight client.
  • docs/OPERATOR_TASKS.md reference identifier table. Every ID accumulated during the operator-task knockdown — App Store Connect app, bundle IDs, subscription product, RevenueCat project / app / entitlement / product / offering / webhook / API key, build id, TestFlight group, sandbox tester, ASC user, etc. — collected at the bottom of the doc so a fresh agent can find anything in one place.
  • docs/audit_prompts/audit_prompt_go-live.md expanded to all five surfaces. SCOPE flipped from "webapp only" to Laravel webapp + marketing homepage + iOS (main + Share Ext + StashKit) + Chrome extension + documentation tree. Domain list grew 10 → 14 to cover iOS App + Share Ext, StashKit, Chrome ext, and docs each as their own cycle-1 review unit. New "TARGETED SUB-AUDITS" section with Sub-audit A — iOS + RevenueCat + Share Extension ↔ backend alignment as a runnable nine-step process. Bootstrap now includes xcodegen generate && xcodebuild build for iOS + jq Chrome-ext manifest validity check. Lessons-learned section picks up four new entries: laravel/mcp require-not-require-dev, iOS Info.plist regenerated by xcodegen, TestFlight invitation-acceptance gate, asc builds add-groups silent no-op.

Changed

  • stash-ios/Stash/App/StashApp.swift is now app-entry-and-paywall surface, not just app entry. Two .sheet(isPresented:) modifiers bind to subscriptionStore.isPresentingPaywall / isPresentingCustomerCenter and present the RevenueCat-supplied views.
  • SubscriptionController::show returns user_id as the first field of the JSON envelope. Cache-Control header unchanged.
  • docs/OPERATOR_TASKS.md rewritten end-to-end. §2 (Apple Dev Portal), §3 (ASC app + subscription product), §4 (RevenueCat dashboard), §5a + §5b (prod .env), §6 (TestFlight first build) all flip to ☑ with the captured IDs / state inline. §5c (OAuth provider creds) and §7–§11 stay ☐. The reference table at the bottom is the one-stop cheat sheet. "Background tasks I'm handling" section rewritten to "Code-side work — all shipped in v2.1.0 / v2.1.1" with every Phase 4 / Phase M tier / Phase L item marked ☑ with version reference, and a footer pointing at the audit prompt as the full-surface audit entry-point.
  • README.md + docs/roadmap.md status refreshed for v2.1.1. Tests badge 103 → 227. Status line bumped to "the entire SaaS launch layer is live" with all shipped phases enumerated (A–G + H + I + K + L + M tiers 2/3/5/7 + N + O + Phase 4). Now/Next/Later reorganized so Phase J skip is called out, operator-side App Store + OAuth creds + Chrome ext reload land under "Now (in flight)", and Phase K-O + Phase 4 sections each get a ✅ shipped banner pointing back to the top while the original design notes stay as historical reference.

Fixed

  • macOS Sequoia openrsync vs Homebrew GNU rsync conflict during xcodebuild -exportArchive. The rsync at /usr/bin/rsync (Apple openrsync) and /opt/homebrew/bin/rsync (GNU 3.4.1) interpret -E (--extended-attributes) differently; xcodebuild's distribution pipeline forks rsync and the second instance gets the wrong rsync off PATH. Workaround documented in docs/OPERATOR_TASKS.md: wrap the export call in PATH="/usr/bin:/bin:/usr/sbin:/sbin" so openrsync calls itself consistently. Without this, the export fails at IDEDistributionCreateIPAStep with Copy failed.
  • TestFlight build invisible to internal testers despite VALID + IN_BETA_TESTING + group-attached state. Two silent gates identified and fixed 2026-05-02. Gate 1: tester invitation state was INVITED (never accepted on device); asc testflight config export reported testers: 0 until acceptance — that count IS the acceptance check. Re-issued via asc testflight testers invite (new invitation id 44be7354-…). Gate 2: build wasn't actually attached to the group despite the doc claiming it was; Apple's API silently no-op'd the original attempt. asc builds add-groups returned "action": "added" (not "already_present") when re-run. Both gotchas documented inline at OPERATOR_TASKS.md §6 ⚠️ for the next operator.

Notes / known limits

  • TestFlight Beta License Agreement was empty by default for the newly-created App Store Connect app. Apple silently hides the build from internal testers until the agreement has text. Filled in this release; future operator notes in OPERATOR_TASKS.md §6 mention the check (was the second blocker after Beta App Localization).
  • Subscription product state stays MISSING_METADATA until submission. Apple requires a 1024×1024 screenshot for the subscription product before the App Store reviewer ticket can proceed; not blocking sandbox testing. See docs/OPERATOR_TASKS.md §9 step 6.
  • Sandbox tester ≠ TestFlight tester. Documented in docs/OPERATOR_TASKS.md §3d: sandbox account stash2self@randomsynergy.com is for IAP receipt validation only (signed in at iOS Settings → App Store → Sandbox Account); TestFlight installs require a real Apple ID (17@randomsynergy.com).
  • REVENUECAT_API_KEY (the RevenueCat secret key, server-side) still not generated. Required only for EntitlementSnapshotJob to reconcile entitlements outside the webhook flow — defer until needed.

[2.1.0] - 2026-05-01

Phases I, K10–K12, L (charts polish), M (tiers 2/3/5), and 4 (AI surface) all land. The three peer clients (iOS / Chrome ext / PWA) now point at https://app.stash2self.com/ — Phase I cutover complete. The four Phase 4 AI features (auto-tag, auto-summary, weekly digest, chat-with-your-stash, and an MCP runtime for external clients) ship behind the Stash2Self Pro subscription gate. Three deferred admin tiers ship: per-user detail page, security dashboards, and content moderation with soft-hide. ApexCharts wires onto the stats page. The legacy darktower stack at stash.randomsynergy.xyz continues to run for personal use and is permanently skipped from Phase J decommission.

Test suite progression: 190 → 227 (+37), 561 → 642 assertions, ~6s on SQLite :memory:. Pint clean across every commit.

Added

  • Phase 4 AI surface — laravel/ai v0.6.5 + Anthropic Claude Haiku 4.5 for all four features. Auto-tag (eager on ingest, structured output to items.ai_tags), auto-summary (lazy on first view, cached on items.ai_summary), weekly digest (Sunday 09:00 UTC fan-out per active subscriber with ai_weekly_digest=true), chat surface at /chat (tool- based grounding via the user's stash, no training-data hallucination), and an MCP runtime at POST /mcp for external clients (Claude Desktop, Claude Code) over Sanctum bearer with the read ability. AI calls short-circuit cleanly on failure (stamp ai_processed_at, log, no queue-storm).
  • Phase 4 client toggles. ai_auto_tag / ai_auto_summary / ai_weekly_digest checkboxes on /settings, default-on for paid users. Tag chips render per-row on the items index when ai_tags is populated; AI summary block + tag chips on the show page.
  • Phase M tier 2 — /admin/users/{user} detail page. Aggregates recent items, auth attempts (keyed on email), admin_actions targeting the user, subscription history, OAuth provider linkages, settings, and personal access tokens into one view. Per-row admin actions (export / impersonate / delete) move from the index list to the detail header; the index list links to detail.
  • Phase M tier 3 — /admin/security dashboards. Failed-logins-24h, 7d failure rate, top failing IPs, top targeted emails, hourly spike chart, deep-link to Telescope's exception store. New compound index on auth_attempts(succeeded, attempted_at).
  • Phase M tier 5 — /admin/moderation. Cross-user free-text search + soft-hide affordance for legal / DMCA / phishing-takedown work. Hidden items render nowhere on the owner's surfaces but stay in DB for restore + audit. New columns: items.hidden_at, items.hidden_by (nullOnDelete), items.hidden_reason. Hide / unhide each write admin_actions.
  • Phase L charts polish — ApexCharts on /admin/stats. Four charts via the ApexCharts CDN: items_created (line), users_signed_up (line), mrr_cents (area, USD), auth_attempts (stacked bar succeeded vs. failed). @stack('scripts') added to the app layout. Text-histogram fallback retained for empty windows.
  • Phase I client paywall affordances (K10–K12). iOS gains a SubscriptionStore (observable wrapper over GET /api/me/subscription, refreshes on launch + scenePhase=.active) + SubscriptionStatus decoder. Chrome ext popup pre-flights the same endpoint and shows a "Stash2Self Pro required" view to free users. PWA items index renders an upgrade card linking to stash2self.com/#pricing when the free-tier 20-item cap is hit.
  • docs/OPERATOR_TASKS.md — punch list of 11 manual steps that need a human at a console (cPanel cron, Apple Developer Portal, App Store Connect, RevenueCat dashboard, OAuth provider creds, TestFlight, Chrome ext per-machine reload). Each item has time estimates, exact URLs, copy-paste values, and verify steps.
  • StashError.paywalled(itemCount, itemLimit, upgradeURL) case in iOS for parsing the 402 free-tier-cap response from /api/ingest. Chrome ext ports to a kind: 'paywalled' result tag with web-URL fallback for the iOS-only stash:// upgrade scheme.

Changed

  • Phase I cutover — clients now point at app.stash2self.com. iOS default endpoint, Chrome extension default endpoint + host_permissions, and the wire-contract path (/ingest/api/ingest) all updated. The legacy stash.randomsynergy.xyz backend stays running per the user direction to skip Phase J — clients on the new bundle / new endpoint hit the Laravel app, clients on the old build keep hitting darktower.
  • iOS bundle IDs rename now executed. Telegraphed in 2.0.0 as part of the migration plan; this release ships the project.yml + Info.plist + entitlement edits. Bundle IDs become com.stash2self.app (main) and com.stash2self.app.share (Share Extension). App Group becomes group.com.stash2self.app. Keychain access group + service updated to match — intentional one-time re-pair on first launch is the migration path; cleaner than grandfathering two access groups into the entitlements file.
  • ItemsController::index and ListJsonController filter through Item::scopeVisible. Moderator-hidden items (Phase M tier 5) render nowhere user-facing. ItemPolicy::view returns denyAsNotFound even for the owner of a hidden item — preserves the per-user 404 contract while honoring the moderation soft-hide.
  • AutoTagItemJob runs the real AutoTagAgent instead of the Phase 4 foundation stub. Failures swallow cleanly + stamp ai_processed_at (idempotent retries already short-circuit). Tests rewritten to cover the real call via Ai::fakeAgent().
  • ItemsController::show lazily dispatches AutoSummaryItemJob when the bookmark has no cached summary + the user is on the paid tier with ai_auto_summary=true.
  • Brand sweep — user-facing "Stash" → "Stash2Self". Blade page titles (5 views), <x-app-layout> and guest-layout title fallbacks, error-page title, PWA manifest short_name, Chrome extension manifest name / short_name / options page heading, Chrome extension popup misconfigured-state heading, chrome.notifications titles for the context-menu surfaces, iOS CFBundleDisplayName (3 places via project.yml + Info.plists), iOS StashError user-facing strings. Internal class names (StashClient, StashKit, StashColors, StashMail) and the repo dir name remain — those are code identifiers, not the product name.
  • UserSettingsController::update accepts the three AI booleans with unchecked-checkbox normalization (absent → false, present → true) so opt-out is honored even when the form omits the input.
  • composer require laravel/ai (v0.6.5). Default provider set to anthropic via AI_DEFAULT_PROVIDER env. Config + agent_conversations migration + agent / structured-agent / tool / agent-middleware stubs published into the project.

Removed

  • Nothing.

Fixed

  • Nothing — this was a feature batch.

Notes / known limits

  • Phase 4 stays dormant in production until ANTHROPIC_API_KEY lands in .env. All four agents short-circuit gracefully without it; the scaffolding works in tests via Ai::fakeAgent() regardless.
  • Phase J — darktower decommission — permanently skipped per user direction. The legacy stack at stash.randomsynergy.xyz keeps running indefinitely as a personal stash. Out of scope for further development.
  • Cron is wired as of this release (* * * * * php artisan schedule:run confirmed in cPanel). All scheduled tasks fire — daily prunes plus the new RollupDailyStatsJob (00:05), weekly DispatchWeeklyDigestsJob (Sunday 09:00 UTC), and EntitlementSnapshotJob (03:15, no-op until REVENUECAT_API_KEY configured).
  • iOS Info.plist files are gitignored because xcodegen generate rebuilds them from project.yml. The bundle-display-name change in this release ships via project.yml; Info.plists regenerate locally.

[2.0.0] - 2026-04-29

The migration ships. Stash2Self has moved off the legacy darktower Docker stack onto a Laravel 13 + cPanel + MariaDB SaaS at https://app.stash2self.com/, with the marketing homepage at https://stash2self.com/ and the foundation in place for a $3/mo subscription launch (Phase K, queued post-J).

This release packs migration phases A–G plus a six-agent expert audit (five waves of fixes), the marketing homepage with full SEO, four legal pages (privacy / terms / refunds / changelog), and the observability plumbing for production traffic. The legacy darktower stack is retired in spirit and decommissions at phase J of the migration plan; emergency edits during the parallel-running window go directly to /docker/appdata/stash-web/ on darktower.

Changed

  • Backend runtime: Docker → Laravel 13 + cPanel. Production now runs PHP 8.4.19 + Laravel 13.7 + MariaDB 10.11.16 on cPanel shared hosting at cpanel86.gzo.com. The legacy RanSynSrv image / SQLite / Nginx / NPM stack at stash.randomsynergy.xyz is retired in spirit; source is gitignored under _ref/_legacy/stash-web/. Decommission lands at phase J of the migration plan.
  • Database: SQLite → MariaDB. Schema now requires Schema::defaultStringLength(191) in App\Providers\AppServiceProvider::boot to fit cPanel's MariaDB utf8mb4 1000-byte index-key cap. Pulse migration's pulse_aggregates type and aggregate columns trimmed to VARCHAR(64) for the same reason.
  • Auth: three shared secrets → per-user accounts. BEARER_TOKEN, ADMIN_PASSWORD, and access_password all retired. New surfaces: Breeze (email/password) + Socialite (Google/Apple/GitHub) for the web layer; per-device Sanctum personal access tokens for iOS / Chrome ext / PWA. users.is_root_admin boolean replaces the global ADMIN_PASSWORD; per-user admin (own bookmarks / own tokens / own settings) needs no role.
  • Domain layout: marketing homepage at https://stash2self.com/ (apex, ~/public_html); app at https://app.stash2self.com/ (subdomain, symlinks to ~/laravel-app/public). DNS + AutoSSL configured.
  • Sender: noreply@stash2self.com (Resend-verified) replaces stash-noreply@randomsynergy.xyz. Live transport verified end-to-end.
  • Per-user destination email now lives in user_settings.destination_email and falls back to users.email. No longer server-locked to a single address — but never caller-supplied either (each user has their own).
  • iOS bundle IDs rename: com.stash2self.app (main), com.stash2self.app.share (Share Extension). App Group group.com.stash2self.app. Team ID CYJSFXX493 unchanged. Earlier xyz.randomsynergy.stash namespace retired; one-time per-device re-pair on update (combined with Sanctum-token rotation).
  • stash-muted design token darkened from #6B6862 to #5A5853 on the light theme — 4.2:1 → 5.4:1 contrast on light backgrounds, WCAG AA pass for small text. Single source of truth in tailwind.config.js + iOS StashColors.swift.
  • Marketing homepage copy + structure rewritten end-to-end. Three pillars expanded to a 3×2 feature grid (inbox / privacy / multi-device + filter+search / auto-tags Pro / chat-with-stash Pro). Pricing card, FAQ section, install card with App Store badges, social-proof section, footer with all legal links.

Added

  • Laravel 13 project at stash-web/app/ — Breeze, Socialite, Sanctum, Boost (require-dev), Resend mail driver, Telescope + Pulse (production observability). 103 tests / 246 assertions / green. CI on every push to main + every PR via .github/workflows/ci.yml (PHPUnit + Pint).
  • Per-user data isolationApp\Policies\ItemPolicy with Response::denyAsNotFound() for non-owners (cross-user access is 404, never 403 — no existence leak). Writes via parent relationships ($user->items()->create(...)); user_id removed from #[Fillable] on Item / OauthProvider / UserSetting. is_root_admin and email_verified_at similarly not fillable on User (set via forceFill).
  • App\Http\Resources\ItemResource as the single source of truth for Item public JSON shape. public static $wrap = null preserves the legacy {items: [...]} / bare-object contract that iOS / PWA / Chrome ext consume.
  • App\Listeners\LogAuthAttempt writes to auth_attempts on every login (success + failure). Single-arg Event::listen([Class, 'handle']) registration — Laravel auto-discovers the union-typed handle(Login|Failed).
  • App\Mail\StashItemMail composes subject as {preface}{title-or-url}{suffix} from user_settings. headers() re-validates extra_headers via App\Rules\SafeHttpHeaders before applying. IngestController captures $sent->getMessageId() to items.resend_id.
  • PHP-backed enumsApp\Enums\{ResendStatus, ItemSource, OauthProviderName} replace magic strings on Item.source, Item.resend_status, OauthProvider.provider. Eloquent casts handle round-trip.
  • Sanctum tokens — 1-year sliding expiry (config/sanctum.php expiration: 525600). Daily sanctum:prune-expired --hours=24 removes expired rows. oauth_providers.raw was dropped — was storing access tokens unencrypted with no consumer.
  • Rate limitingPOST /api/ingest 60/min/user; POST /register 10/min/IP; POST /forgot-password 5/min/IP. Login already throttled by Breeze (5/min/email+IP).
  • /healthz deep probe — DB + required-config exercise. Returns {"ok":true} on success (no item count leak, no missing-key echo); {"ok":false,"error":"..."} on failure.
  • PWA Service Worker v2 — intercepts /api/list.json + /api/item/{id}.json (was legacy /list.json); logout intercept runs before the GET-only guard; /build/* Vite hashed assets cached network-first.
  • Marketing homepage at stash2self.com — single-page PHP + Tailwind (Play CDN) + Alpine.js. Hero, "Why it works" 3×2 feature grid, "How it works" 4-step, pricing card, install card with App Store/Play/Chrome badges, FAQ, footer.
  • Full SEO suite on the homepage — OpenGraph + Twitter Card + JSON-LD (Organization, WebSite, SoftwareApplication, Offer, FAQPage), og:image at 1200×630 / 105 KB JPEG with og:image:secure_url + og:image:type, sitemap.xml, robots.txt, canonical URL, theme-color, manifest, favicons (16/32/192/512 + apple-touch-icon).
  • Legal pages/privacy, /terms, /refunds, /changelog (placeholder). Shared _legal_layout.php shell mirrors the homepage's brand + dark/light toggle. .htaccess rewrite for clean URLs.
  • Custom error pages at errors/{404,403,419,500,503}.blade.php with errors/minimal.blade.php shared layout.
  • /admin/telescope + /admin/pulse — both is_root_admin-gated. Telescope production filter records only exceptions / failed requests / failed jobs / scheduled tasks. password, password_confirmation, current_password, token, remember_token, and Authorization masked from request bodies.
  • Daily scheduled prunes in routes/console.php: auth_attempts (>30d), Telescope (--hours=168), Pulse (trim), Sanctum tokens (--hours=24), sessions (>30d).
  • MustVerifyEmail contract on Userverified middleware enforced on every web route except /login, /register, /forgot-password, /reset-password, /healthz, the API endpoints, and the verify-email flow itself.
  • Unified deploy script _helpers/deploy.sh — two targets (app Laravel + homepage static), per-target subcommands (all / file / dir / status / help), php artisan test gate before app deploys (--skip-tests to bypass), composer install + artisan migrate + cache:warm + queue:restart on the remote, /healthz auto-smoke. Bash 3.2 compatible (no local -n nameref).
  • Six-agent expert audit of the full migration surface, in five waves of fixes (commits ac1963a03d9dcb):
  • Wave 1 — security hardening (Telescope masking, ingest / register / forgot-password rate limits, OAuth callback session regenerate + wasRecentlyCreated guard on email_verified_at).
  • Wave 2 — decision-driven changes (drop oauth_providers.raw, 1-yr Sanctum expiry, PHP-backed enums for source / resend_status / provider).
  • Wave 3 — frontend brand + a11y + error pages (Breeze auth views restyled with stash-* tokens, settings inputs got for=/id= association, custom error templates).
  • Wave 4 — perf + test gaps (/healthz no-leak posture, items index single data-search attribute, SW v2 cache strategy fix, new bearer-token + verified-middleware tests).
  • Wave 5 — CI workflow (.github/workflows/ci.yml: PHPUnit + Pint on every push + every PR) and CLAUDE.md major rewrite documenting v2.0.0 reality (legacy darktower section tombstoned to ~30 lines).
  • Boost MCP (laravel/boost, require-dev) — exposes the running app to Claude Code / Cursor during development. Build-time productivity tool, not a product feature.

Removed

  • _ref/_legacy/stash-web/ is now gitignored — local-only reference to the pre-migration darktower codebase. Preserved at repo sha 56dea55 for archival access.
  • xyz.randomsynergy.stash Keychain access group / bundle prefix — replaced by com.stash2self.app family.

Security

  • OAuth callback auto-verifies email_verified_at ONLY when $user->wasRecentlyCreated. Closes a bypass where an attacker who registers a victim's email unverified would have its row auto-verified by a later Google OAuth callback for the same email.
  • Session-fixation guardsession()->regenerate() after Auth::login in OAuth callback.
  • Telescope production filter masks password, password_confirmation, current_password, token, remember_token, and the Authorization header from request bodies.
  • /healthz returns generic {"ok":false,"error":"..."} on failure with no item-count or config-key leakage; details logged server-side.

Documentation

  • CLAUDE.md major rewrite (Wave 5) — new v2.0.0 batch entry, "What Stash is" / "Architecture essentials" / "Locked decisions" sections rewritten for the SaaS-on-Laravel-on-cPanel current state. RanSynSrv-era content compacted to a tombstone block pointing at the legacy git sha and the migration design doc.
  • README full rewrite — describes the live v2.0.0 reality (was written as "target state for 2.0.0" in v1.5.1).
  • Phase K subscriptions design at docs/plans/2026-04-29-phase-k-subscriptions-design.md — RevenueCat-managed entitlements, $3/mo, free tier 20 items, 14-day Apple-policy refund as trial substitute.
  • Pricing model at docs/plans/2026-04-29-pricing-model.md + App Store submission checklist at docs/plans/2026-04-29-app-store-submission-checklist.md.
  • Roadmap updated — phases K–O queued, Phase 2 items still open, homepage polish reduced to remaining items only.

Not yet

  • Phase I — client cutover. iOS / Chrome ext / PWA repointing at app.stash2self.com, bundle ID rename rollout, one-time re-pair flow.
  • Phase J — decommission darktower. Stop the Portainer stack, remove the NPM proxy host, retire stash.randomsynergy.xyz.
  • Phases K–O — SaaS layer. Subscriptions (K), admin portal day-one (L), post-launch admin (M), stats + charts (N), customer support / status page (O). Phase K design ready; rest queued.
  • Phase 4 — AI surface. Auto-tag / auto-summary / weekly digest / chat-with-your-stash / MCP runtime. Lands after K.
  • OAuth provider credentials — env vars empty in production. Routes register; clicking "Sign in with X" throws until populated. Setup is GitHub 5min, Google 15-20min, Apple 30+min.
  • Queue worker on cPanel. QUEUE_CONNECTION=database is set but no worker runs; current code uses synchronous Mail::to()->send() so the mismatch is harmless today. Phase 4 features that call ->queue() will need either sync driver or a wired worker before they ship.

[1.5.1] - 2026-04-29

Operational hardening on the live darktower stack plus all the foundation work for the Stash2Self migration (Laravel 13 + cPanel + per-user auth + MariaDB). The darktower stack still serves stash.randomsynergy.xyz unchanged through this release; the migration target is documented and scaffolded but not yet shipped — see roadmap and migration plan.

Security

  • RanSynSrv image pinned to a digest. stash-web/docker-compose.yml now references ghcr.io/randomsynergy17/ransynsrv@sha256:3ff2df…d77d5 instead of the floating :latest tag. Closes the supply-chain footgun where a surprise upstream push could land in production on the next bounce or watchtower run. com.centurylinklabs.watchtower.enable=false added as a belt-and-suspenders label.

Added

  • Stash2Self domain configuration on cPanelstash2self.com (main, Force HTTPS on) → ~/public_html for a static marketing homepage; app.stash2self.com (subdomain) → ~/app.stash2self.com (will become a symlink to ~/laravel-app/public in plan phase G). DNS + AutoSSL set up.
  • Unified deploy script at _helpers/deploy.sh — rsync-over-SSH with two targets (app for the future Laravel project, homepage for the static site) and per-target subcommands (all/file/dir/status/help). The app target gates on php artisan test (skippable with --skip-tests), runs composer install + artisan migrate + cache:warm + queue:restart on the remote, and auto-smokes /healthz after deploy. Replaces the earlier stash-web/scripts/deploy.sh which was vanilla-PHP-shaped.
  • stash-web/app/ placeholder so the Laravel project's eventual home exists in git ahead of plan phase A bootstrapping.
  • stash-web/homepage/index.html — minimal "coming soon" landing page in the brand palette (deep teal #2F6B6B, Charter serif), ready to deploy via _helpers/deploy.sh homepage all.
  • App Store Connect tooling globally installedasc CLI (Homebrew) plus the 22-skill app-store-connect-cli-skills pack at ~/.agents/skills/asc-*, symlinked into Claude Code's skills directory. Used during the eventual TestFlight prep for Stash2Self.

Documentation

  • Stash2Self migration design doc at docs/plans/2026-04-28-stash2self-laravel-migration-design.md — locks the target architecture (Laravel 13 + Breeze + Socialite + Sanctum on cPanel/MariaDB), per-user data isolation from migration 0001, conflict list against current "locked decisions" in CLAUDE.md, cPanel directory layout, build sequence A–J, decommission strategy.
  • Migration implementation plan at docs/plans/2026-04-28-stash2self-laravel-migration-plan.md — task-by-task breakdown for phases A through J. Resolves a full review pass: B-series execution-blockers (.env quoting, install:api path, config() not env() in healthz, controller middleware syntax, missing Request $request), G-series gaps (iOS endpoint URLs across 4 files, stub view ordering, extra_headers validation rule, transaction-wrapped data import), and S-series doc touches (Chrome ext host_permissions diff, secret rotation procedure, project-specific Laravel app README).
  • Locked design decisions for the Phase 4 AI surface (auto-tag, auto-summary, weekly digest, chat-with-your-stash, MCP runtime): Laravel 13's first-party AI SDK (composer require laravel/ai), Haiku 4.5 across all four LLM features, no vector store (MariaDB 10.11 doesn't have native VECTOR), MariaDB FULLTEXT for search, full-stash-as-LLM-context for chat (no embedding/retrieval layer), auto-tag default-on per-user-toggleable. Captured in the design doc; README rewrite to follow.
  • Roadmap Phase 4 stub at docs/roadmap.md — runtime laravel/mcp, Prism-style content features (now built on the AI SDK), chat surface. Lands after migration ships.
  • CLAUDE.md updated to flag the in-progress migration, point at the design and plan, and note that the production state described elsewhere in the file is being supplanted.

Not yet

  • The actual Laravel rewrite + cPanel deploy + per-user auth — that's phases A through J of the migration plan. Tracked toward 2.0.0.
  • Auto-tag / auto-summary / chat / MCP runtime — Phase 4, after migration ships.
  • iOS bundle ID rename to a com.stash2self.app namespace — separate decision; not blocking the migration.
  • Wordmark / icon redesign for "stash" → "stash2self" — open design question.

[1.5.0] - 2026-04-25

A third peer client of the same Stash backend: a Chromium-family browser extension. Toolbar popup matches the iOS Share Extension's UX; right-click context menus + keyboard shortcut are Chrome-native idioms with no iOS analogue. No backend change required — extension fetches use the existing /ingest and /healthz endpoints with the same bearer token.

Added

  • stash-chromeExt/ — Manifest V3 extension. Loaded unpacked (no Chrome Web Store). Tested in Chrome and Edge; works unchanged in Brave, Vivaldi, Opera. Three siblings to stash-ios/ and stash-web/, all clients of the same wire contract.
  • Toolbar popup with the same five states as the iOS Share Extension (idle / sending / success / partial / error / misconfigured). Pre-fills URL + title from the active tab; add an optional note; Send routes through the SW so closing the popup mid-send doesn't abort the in-flight fetch(). Cmd/Ctrl+Enter from the note field triggers send.
  • Right-click context menus — "Stash this page", "Stash this link", "Stash with note: <selection>". One-action sends; status surfaces as a Chrome notification toast.
  • Keyboard shortcut Cmd+Shift+S (Mac) / Ctrl+Shift+S (everywhere else) opens the popup pre-filled. Re-bindable at chrome://extensions/shortcuts.
  • Options page mirroring stash-ios/Stash/App/ConfigView.swift: endpoint URL, bearer token (with paste validation matching v1.3.0 — trim, length ≥ 16, reject whitespace inside), destination email, subject preface/suffix, custom headers, test-connection, send-test-bookmark, view-bookmarks. Saves debounced 300 ms after the last keystroke.
  • source: 'chrome_extension' field on /ingest for source-tagging in the items table (distinct from iOS's 'share_extension'). Visible at /item/<id> for admin.

Security

  • Bearer + config in chrome.storage.local (per-device, not Google- synced — secrets stay on this machine, mirroring the iOS Keychain posture).
  • host_permissions declares the Stash backend only; MV3 SW fetch() is exempt from CORS preflight on those origins. No backend change required.
  • activeTab (vs tabs) — minimum-privilege permission. The extension only sees the current tab's URL + title on user-invoked actions.
  • No remote scripts, no eval, no inline <script> in popup/options HTML.
  • Misconfigured (missing bearer / missing destination / malformed endpoint) detection on every send path; context-menu invocations surface a notification + open the options page rather than failing silently.

Documentation

  • stash-chromeExt/README.md — load-unpacked install steps, file map, surface-by-surface verification list.
  • CLAUDE.md repository-layout section gets the third peer client. Repository-conventions section adds the chrome-ext bullet documenting the storage choice + hard-coded host_permissions.
  • README repository-layout tree updated; roadmap row added.
  • Design + plan briefs at docs/plans/2026-04-25-chrome-extension-design.md and docs/plans/2026-04-25-chrome-extension-plan.md.

Not yet

  • Firefox support — needs browser_specific_settings and a chrome.* → browser.* polyfill. Deferred.
  • Offline write queue — failed sends surface a clear error; no persistent retry. Phase 2 candidate.
  • Chrome Web Store listing — explicitly out of scope for a single-user personal app.
  • Dynamic host_permissions — endpoint URL changes require editing manifest.json and reloading the extension.

[1.4.0] - 2026-04-25

A full PWA conversion of the web frontend with offline list-cache support. Installs to home-screen on iOS/Android and the dock on desktop, serves the most recent list from cache when offline, and clears that cache on logout so a session expiry genuinely revokes data access in the browser.

Added

  • Installable PWA manifest with display: standalone, start_url: /, scope: /, plus a maskable icon variant at /icon-512-maskable.png (envelope master padded into the maskable safe-zone).
  • GET /list.json and GET /item/<id>.json — JSON siblings of the HTML list and detail views, gated by the same auth and shaped admin-vs-non-admin identically. Cache-Control: private, max-age=0, must-revalidate so the service worker is the only cache layer.
  • Service worker at /sw.js — stale-while-revalidate for JSON data, cache-first for static assets, cache-first-with-fallback for Google favicon-service responses (inline SVG globe when offline and uncached), network-only for everything mutating or auth-related (/ingest, /settings*, /access, /healthz, /logout).
  • Clear-on-logout — SW watches /logout fetches and 302 → /access responses on /list.json or /item/<id>.json; either trigger purges stash-data-v1 and stash-favicon-v1. Static cache preserved (no PII). Belt-and-suspenders postMessage({type:'clear-stash-cache'}) hook exposed for explicit invalidation.
  • "synced N min ago" indicator in the top bar of the list view, sourced from synced_at in the JSON response.
  • Alpine factories at /static/stash-list.js and /static/stash-item.js.

Changed

  • views/list.php and views/item.php are now thin shells. The bookmark list and detail views are hydrated client-side from JSON. Settings, access, and healthz remain server-rendered (and JS-optional).
  • Locked decision re-opened: the "list renders without JS" invariant from CLAUDE.md is intentionally dropped for / and /item/:id. JS-blocked clients see the loading shell indefinitely. CLAUDE.md and README updated to reflect the new posture.

[1.3.0] - 2026-04-24

A cross-domain audit pass (four parallel expert agents — web, iOS, QA, documentation). Surfaced ~25 findings; this release lands the code fixes and documentation truth-ups, defers two known-tradeoff security items per explicit decision, and removes dead code.

Fixed

  • Open-redirect shape /\ in sanitizeNext. Browsers normalize backslash into the authority portion, so /access?next=/\attacker.com would produce a scheme-relative redirect. The existing // check didn't cover it. Now rejected alongside //, CR/LF, and NUL.
  • Privacy: /item/:id was showing destination_email to anyone holding the access password. That's the user's private inbox address, same privacy tier as the already-hidden Resend error text. Now wrapped in $isAdmin like the rest of the status block.
  • StashClient.perform() was replacing the real URLError with a synthetic .unknown on any non-URLError. ShareRootView branches on URLError .code (e.g. filtering .cancelled), so the replacement broke cancellation handling. Now preserves the original URLError; CancellationError is re-thrown as CancellationError for the structured-concurrency path.
  • Clipboard paste into the bearer-token field had zero validation — pasting whitespace, multi-line text, or a too-short string silently replaced the stored token with nothing meaningful. Now trims, checks length ≥ 16, rejects whitespace inside the value, and surfaces a one-line inline error via the existing test-connection slot.
  • StashConfig.save() encoding failures were silent in Release (assertionFailure is a no-op outside Debug). Switched to NSLog so a failed write surfaces in the device log.

Changed

  • IngestPayload no longer sends destinationEmail. The server has been destination-locked since v1.1.0 and silently ignored the field. Removing it stops leaking the user's inbox on every ingest request body. Misconfigured detection still reads StashConfig.destinationEmail directly (not from the payload).
  • IngestResponse.resend_id renamed to resendId with explicit CodingKey = "resend_id", matching the IngestPayload camelCase + explicit-CodingKeys pattern so both directions of the contract pin the wire format the same way.
  • Share-sheet favicon URL now percent-encodes the host before embedding it in the Google favicon-service query. IDN and unusual hosts no longer produce a silently-failed image load.

Removed

  • Dead Color.stashError — defined in StashColors but never referenced anywhere in either iOS target. Call sites that display errors use .red directly.
  • Dead asset colorsetsStash/Assets.xcassets/StashAccent.colorset, StashSuccess.colorset, and both AccentColor.colorsets (in Stash/ and StashShare/). All were unused; UI colors come from StashColors.swift as the single source of truth, and .tint() is applied explicitly on every root view. Kept: StashBackground.colorset (referenced by the launch-screen in Info.plist) and both AppIcon.appiconsets.

Documentation

  • Closed v1.2.x open items in CLAUDE.md and README: og:title fetch, accent-color decision, icon concept — all shipped in v1.2.0/1.2.1.
  • Truth-up on HTTP_BIND. CLAUDE.md, root README, and stash-web/README all still claimed the compose file defaults to 127.0.0.1; actual default has been 0.0.0.0 since v1.1.1. Every doc now reflects the compose reality plus the rationale (NPM runs in its own Docker-network namespace on darktower and can't reach host loopback). Queued a "shared Docker network" open item as the prerequisite for restoring loopback.
  • Accent-color resolution documented. Both design tokens are locked: UI accent = deep teal #2F6B6B, icon envelope glyph = amber #C89B3C (intentionally different — chrome vs. brand mark).
  • _open_settings contract written down in CLAUDE.md. Any new form in views/_settings_panel.php must carry <input type="hidden" name="_open_settings" value="1">; row-level forms (delete) must omit it. Prevents future reintroduction of the v1.2.0 "delete-pops-the-modal" bug.
  • Repository conventions section added to CLAUDE.md documenting docs/plans/ + docs/icon/ layout and the fact that the Xcode project is regenerated by xcodegen.
  • HANDOVER_CLAUDE_DESIGN.md gets a superseded banner pointing readers at CLAUDE.md for the load-bearing decisions that have changed (icon, accent, timeouts, auto-dismiss).
  • README Phase 1 acceptance checklist — backend boxes flipped from [ ] to [x]; they've been demonstrably passing since v1.1.0.
  • .env.example demotes INGEST_TOKEN from an equal-tier secret to a commented-out legacy fallback. New deploys should use per-role BEARER_TOKEN + ADMIN_PASSWORD.

Accepted tradeoffs (explicitly not fixed, documented as known)

  • LAN HTTP exposure on port 8042. Documented the attack surface (LAN peer forging X-Real-IP can exhaust the auth-rate-limiter allowance per forged IP) and the accepted rationale (single-user personal app on a trusted LAN; fix path queued as shared-Docker-network open item).
  • iOS accepts http:// endpoints. User can configure a plaintext endpoint and the Share Extension will POST the bearer token over it. Documented; local-dev convenience outweighs the mis-config risk for this deployment.

[1.2.1] - 2026-04-24

Changed

  • Stash icon master re-iterated for better readability at the 29 pt Settings-icon surface. Envelope is now upright (0° rotation), scaled up so its silhouette is the dominant mass; drawer flattened from a 3/4 tray to a letterbox slot; seam line on the envelope body removed; warm highlight on the drawer lip made subtler. Composite reads as "envelope-into-slot" at 29 pt where v1.2.0 blurred. All sliced iOS and web assets regenerated from the new master.

Added

  • Auto-traced SVG references at docs/icon/stash-icon-silhouette.svg (monochrome silhouette — useful for iOS 18+ tinted icons) and docs/icon/stash-icon.svg (color-layered approximation). Both are auto-traced from the raster master via potrace; the raster PNG remains authoritative for pixel-accurate surfaces. Flagged in the design doc as lossy references, not a hand-crafted source.

[1.2.0] - 2026-04-24

A user-visible branding + reliability release. Ships the Stash icon across iOS (main app + Share Extension) and the web UI, tightens the iOS networking/security layer, and fixes a UX bug where admin deletes were popping the settings modal.

Added

  • Stash icon, single master across all surfaces. Envelope descending into a warm off-white tray on a deep-navy → warm-black gradient (amber #C89B3C accent, palette per HANDOVER_CLAUDE_DESIGN.md §4). Delivered as 1024×1024 PNG into stash-ios/Stash/Assets.xcassets/AppIcon.appiconset/ (iOS 17+ auto- derives smaller sizes), a matching 1024×1024 for the Share Extension appiconset, and a web favicon set (favicon-16/32.png, apple-touch-icon.png, icon-192/512.png, site.webmanifest). <link> tags + <meta name="theme-color"> wired into all four PHP views (list, item, access, settings).
  • Share Extension is now actually embedded in the host app. Added - target: StashShare to the Stash target's dependencies in stash-ios/project.yml; XcodeGen now emits a PBXCopyFilesBuildPhase (dstSubfolderSpec=13 / PlugIns) so the .appex lands at Stash.app/PlugIns/StashShare.appex on install. Without this, iOS never registered the extension and the Stash entry never appeared in the share sheet.
  • og:title fetch in the Share Extension. New TitleFetcher in StashKit does a capped (128 KB), timed (3 s) GET of the shared URL and pulls <meta property="og:title"> or <title> with minimal HTML entity decoding. ShareRootView fires it in a background .task when the host app didn't supply a proper title; the sheet is usable immediately and the title updates in place on arrival.
  • Design documentation at docs/plans/2026-04-24-icon-design.md captures the brief, palette, readability tradeoffs, and generation IDs so the icon can be iterated without rediscovering context.

Changed

  • IngestPayload is idiomatic Swift again. Stored properties renamed to camelCase; CodingKeys pins the wire format to snake_case explicitly. A future rename can no longer silently break the /ingest contract.
  • StashClient normalizes the endpoint URL. URLComponents now strips any path/query/fragment from the user-supplied endpoint before appending /ingest or /healthz. Pasting https://stash.randomsynergy.xyz/app no longer produces wrong /app/ingest requests.
  • Keychain access group is now team-prefixed (CYJSFXX493.xyz.randomsynergy.stash). The bare group name works on simulator but has been reported to be flaky on real devices; the qualified form is unambiguous.
  • /ingest timeout bumped to 30 s (iOS), default source value is now "share_extension".

Fixed

  • Deleting an item from the list (or the item detail page) opened the admin settings modal. flashRedirect() was hardcoding settings=open into every post-action redirect, and views/list.php reads $_GET['settings'] === 'open' to decide whether to pop the modal on load. Row actions that originate outside the panel (delete) had no way to opt out. Fix splits the concerns: flashRedirect($return, $flash, $msg, $openSettings) now takes an explicit boolean (default false), and every form inside views/_settings_panel.php opts in via an _open_settings=1 hidden input. Delete forms don't set the flag, so the post-delete redirect is plain and the list renders without the modal.
  • /settings and /access were throwing a favicon 404 on every load — both pages were missing the <link rel="icon"> block. Added the same five-line block used by list.php and item.php.

[1.1.3] - 2026-04-24

Changed

  • README.md architecture diagram redrawn using pure ASCII (+, -, |, v, ^) instead of Unicode box-drawing characters. Improves rendering in monospaced contexts that don't ship a font with box-drawing glyphs (some terminals, GitHub mobile in certain themes, plain-text email quotes). No behavior change.

Added

  • Root .gitignore excluding .DS_Store, .claude/, and .playwright-mcp/ so MCP-generated test artifacts can't enter future commits. Subfolders stash-web/ and stash-ios/ retain their own nested .gitignores.

[1.1.2] - 2026-04-24

Fixed

  • Homepage list rendered for a split-second then disappeared — Alpine was throwing Unexpected token ';' on the body's x-data because json_encode() output was embedded raw into a double-quoted HTML attribute, so the first " inside the JSON prematurely closed the attribute. Browser received a truncated x-data, Alpine failed to parse, every x-show="!q || …" then threw q is not defined, and Alpine hides elements whose x-show expression errors. Wrapped the json_encode() output in htmlspecialchars (via the existing $esc helper) so " gets encoded as &quot;. Confirmed with Playwright: 0 console errors, all 8 items render.

[1.1.1] - 2026-04-24

Changed

  • docker-compose.yml port binding reverted to 0.0.0.0 default. The 1.1.0 attempt at loopback-only binding broke NPM routing because NPM runs in its own Docker network namespace and can't reach the host's 127.0.0.1. The proper fix — shared Docker network with NPM → http://stash:80 (container-name DNS) — is queued as an open item.

Fixed

  • README.md architecture diagram and surrounding copy were describing a Tailscale/tailnet-gated browse path that was never implemented. Redrew the diagram to show the actual three-secret app-layer access model (bearer / admin / optional access password) and removed stale "tailnet only", "Tailscale-gated at the reverse proxy", and "only loads on the tailnet" language. Added a pointer to the still-open item for proxy-level ACL.

[1.1.0] - 2026-04-24

A security + UX hardening pass informed by a three-wave audit (domain specialists → cross-cutting flows → adversarial) with a final-pass review. Three coordinated surfaces change: backend, iOS, infra. The iOS client still interoperates with the old backend and vice-versa for the core /ingest path; cookie changes force a single web re-sign-in after deploy.

Added

  • GET /settings/reveal?key=<key> endpoint. Admin-cookie-gated JSON endpoint returning plaintext for bearer_token, admin_password, access_password, resend_api_key. The admin panel "show" button now fetches from this instead of baking secrets into HTML source.
  • Per-IP rate limiting on /access and /settings login: 10 failed attempts per 15-min rolling window. New lib/RateLimit.php with an atomic checkAndRecordFailure() variant that wraps check + insert in a BEGIN IMMEDIATE transaction so parallel bursts can't slip past the pre-check.
  • Schema migration gate via PRAGMA user_version in lib/Database.php. Future column adds now have a safe rollout path on an existing DB.
  • stash-web/scripts/backup-stash-db.sh. Nightly SQLite .backup cron with 14-day retention and a trap that cleans the in-container staging file on error.
  • "Copy to clipboard" buttons next to each revealed secret.
  • "Showing N of M items" notice in the list footer when items exceed the 500-row render cap.
  • Settings modal focus management — first input focused on open, the trigger button refocused on close.
  • Deep /healthz probe: returns 503 plus a short error code (db_unavailable / config_incomplete) when the DB is unreachable or any required setting fails to resolve.
  • iOS ConfigViewModel.flushPendingSave() called from a scene-phase observer so debounced edits are persisted before backgrounding.

Changed

  • POST /ingest:
  • destination_email is now server-locked; caller override is ignored. (Previously any bearer-token holder could relay email from the verified Resend sender to arbitrary recipients.)
  • extra_headers sanitized: CR/LF/NUL stripped, keys must match [A-Za-z0-9\-]+, values capped at 500 chars, max 10 entries.
  • subject_preface/subject_suffix/title stripped of CR/LF/NUL to prevent Subject-line header injection.
  • Request body size capped at 1 MiB (Json::MAX_BODY_BYTES).
  • 502 response now returns a generic message; the full Resend error goes to the server log only.
  • Bearer rotation (generate_bearer_token, set_bearer_token, clear_bearer_token) now requires _current_password as a second factor. A stolen admin cookie alone can no longer rotate iOS credentials.
  • Cookies changed from SameSite=Lax to SameSite=Strict, and Secure is now unconditional (was tied to X-Forwarded-Proto). Trade-off: clicking an email "view in Stash" link requires re-signing-in on the access gate. Eliminates the CSRF class and the X-Forwarded-Proto-spoof vector.
  • iOS /ingest timeout raised from 10 s to 30 s. Prevents duplicate sends when the server already completed but the client times out waiting for the Resend round-trip.
  • Share Extension misconfigured state now also triggered by an empty destination email or invalid endpoint URL scheme.
  • Share Extension Cancel now cancels the in-flight URLSession task. Success auto-dismiss: 600 ms → 1.5 s. Partial success (row saved, email failed) no longer auto-dismisses.
  • Share Extension NSItemProvider.loadItem bounded at 2 s via a withCheckedContinuation + GCD timer with a first-wins flag, so a hung host provider cannot freeze the share sheet.
  • ConfigViewModel.scheduleSave Task now @MainActor-isolated to avoid a UserDefaults write race after Task.sleep resumes.
  • docker-compose.yml: port binding defaults to 127.0.0.1:8042 (was 0.0.0.0:8042) so LAN peers can't bypass NPM's TLS; GoAccess defaults to off; JSON-file logging capped at 10 MB × 3 files; ADMIN_PASSWORD fail-fast required via :?.
  • Web frontend: <li> list items, the flash banner, and the settings modal no longer depend on Alpine to become visible. Server-rendered default visibility, Alpine takes over once hydrated.
  • Web frontend: synchronous dark-mode detection added to the <head> of list.php and item.php to avoid flash-of-light-content, both wrapped in try/catch for Safari private-mode.
  • item.php: raw pending/failed email status and Resend error strings hidden from non-admin viewers — they see "delivery pending" / "delivery delayed" instead.
  • lib/Email.php: URLs without an http/https scheme rendered as plain text rather than as clickable links.
  • Docs: CLAUDE.md, README.md, and stash-web/README.md updated to reflect all of the above. Handover files in _ref/ got pinned "superseded" notes rather than rewrites.

Fixed

  • Homepage list rendered empty on mobile Safari despite items existing in the DB. x-cloak on <li> elements kept them at display: none !important until Alpine initialized, which fails under content-blockers or CDN interruptions.
  • Debounced config save could be lost when the user edited a field and immediately backgrounded the iOS app.
  • "Send test bookmark" silently dispatched to the literal you@example.com when destination email was blank.
  • Dead code removed from StashClient.swift (unused failable init).

Security

  • CSRF class eliminated via SameSite=Strict cookies and a cleaner stolen-cookie blast-radius (bearer rotation now 2FA-gated).
  • Phishing-via-Stash's-verified-sender closed (destination_email override removed).
  • Header injection closed (extra_headers key/value sanitization + subject_preface/suffix/title CRLF strip).
  • Brute-force on login endpoints bounded by rate limiter.
  • Credential extraction via admin HTML source closed — secrets are never rendered into the page, fetched on demand instead.
  • LAN plain-HTTP exposure closed — container port bound to loopback only.
  • Top-level 500 response body no longer leaks exception messages to unauthenticated callers.

[1.0.0] - 2026-04-23


Questions about this page? Email support@stash2self.com.