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/ingestacceptstags[](max 10, lowercase[a-z0-9-], ≤32 chars); list/item JSON carrytags. 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. NewPATCH /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 adeleted[]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/ingestso a replay never creates a duplicate bookmark or email. - Email-in: enable
<alias>@in.stash2self.comon 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 setRESEND_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
ItemResourcenow exposesupdated_at(clients previously could not detect edits);itemssource enum gainsemail_inandsiri_shortcut.- CI gained an
ios-buildjob (compiles the app + Share Extension, not just the StashKit package) and achrome-ext-testjob (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://upgradedeep 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/ingestover 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:
TokenImportutility 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 (
’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/tokensat 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/statsApexCharts (#2F6B6Bstash-accent /#4A8C5Estash-success /#B85C5Cstash-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-v1database and the localStorage sync cursor on PWA logout (service-worker fetch intercept onPOST /logout+ page-sideSyncManager.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 anavigator.locksWeb Lock namedstash-syncso 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 (
pendingTabIdviachrome.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_URIecho — UTM parameters leaked into the canonical) - Stop rendering escaped HTML markup in the changelog page's meta description (new
$page_descriptionlayout 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_DATEquery param — returns only items whereupdated_at > since. Carbon::parse with try/catch; 422 on invalid timestamp. Covered byListJsonSinceTest. ai_summaryfield inItemResource::toArray()(was on the model but absent from the JSON shape — clients couldn't read AI summaries).- StashKit
BookmarkItem,ListResponse,BookmarkDetailCodable types.BookmarkDetailis a typealias forBookmarkItem(same ItemResource on both list and detail endpoints). - StashKit
CachedBookmarkSwiftData@Modelentity.updatedAtset tosyncedAtat init time (not decoded from JSON —updated_atis not in ItemResource). - StashKit
SyncEngineactor withfullSync(client:context:),incrementalSync(client:context:), andsyncAfterIngest(client:context:).ModelContextpassed per-call (Swift Sendability — never stored on the actor).lastSyncedAtstored asDouble (timeIntervalSince1970)in App GroupUserDefaultsso the Share Extension reads/writes the same cursor.maxPages=50guards against runaway pagination. - StashKit
StashClient.listItems(page:since:)andStashClient.getItem(id:). ISO8601DateFormatter with.withFractionalSecondsfor the?since=query param; JSONDecoder.iso8601for response date decoding. - iOS
BookmarkListView(native SwiftUI list with@Queryover SwiftData,.searchablefor offline search, pull-to-refresh, gear-icon Config sheet withonDismissreload). - 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 bySubscriptionStore.status?.canUseAi; paywall nudge for free users). - iOS
BookmarkListViewModel(@MainActor @ObservablewithisLoading,error,searchText,hasBearerToken). - iOS root swap:
BookmarkListViewreplacesConfigViewas the app root.ConfigViewis now presented as a gear-icon.sheet. - iOS
BGAppRefreshTaskregistered with identifiercom.stash2self.app.refreshfor background incremental sync. Handler + scheduler are static methods to avoid self capture inBGTaskScheduler.shared.registerclosure. - Share Extension fires
SyncEngine.syncAfterIngest()after a successfulPOST /api/ingest(fire-and-forget; never blocks the share flow).makeSyncContainer()usestry?since Share Extension processes are short-lived and App Group access should be graceful. - iOS
ConfigViewDone toolbar button (@Environment(\.dismiss)). - PWA
resources/js/sync.js—SyncManagerclass with vanilla IndexedDB (stash-v1DB,bookmarksstore,created_atindex ONLY —updated_atis not in ItemResource).boot(),fullSync(),incrementalSync(),upsertMany(),getAll(),deleteOne().MAX_PAGES=50cap. All fetches usecredentials: 'include'for Sanctum cookie auth. - PWA
resources/js/list-app.js— AlpinestashListcomponent that reads from IndexedDB on init, runs background sync if online, filtersfilteredItemsclient-side across title/note/url. CSRF-token-protected delete via fetch DELETE. - PWA service worker
VERSIONbumped tov3— activate handler sweeps the oldv2static + 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 encodingstash2self://connect?token=…; iOS imports it via system-camera deep link (StashApp.onOpenURL→KeychainStore) or the in-app VisionKit scanner (QRScannerView+ConfigViewModel.handleQRScan()). URL scheme +NSCameraUsageDescriptionregistered inproject.yml.
Changed
- iOS
ConfigView"View bookmarks" Safari button removed (redundant with the new native list). - PWA
resources/views/items/index.blade.phpconverted from server-rendered@forelsepagination to an Alpinex-data="stashList"app shell with<template x-for>overfilteredItems. 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_statusbadges (email failed / pending) included via<template x-if>. ItemsController::indexis unchanged but the new app-shell template silently ignores$itemsand$q. Per-user data isolation is now enforced at the API layer (covered byListJsonTest::test_returns_only_authenticated_users_items).Tests\Feature\ItemsControllerTest::test_index_only_shows_authenticated_users_itemsandtest_dashboard_alias_returns_items_indexupdated to assert the app-shell marker (x-data="stashList") instead of server-rendered item titles.- Bookmark list sort changed from
latest()(created_at) tolatest('updated_at')inListJsonControllerfor correct incremental-sync ordering. The first page of?since=is therefore the items most recently AI-enriched (covers items updated byAutoTagItemJobandAutoSummaryItemJobafter 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.ImpersonateControllernow storesimpersonator_namealongsideimpersonator_email; banner reads name with email fallback. (audit PLAYWRIGHT-BANNER-001) items_createdchart on/admin/statsnow renders.RollupDailyStatsJobwrote per-source dimension rows only; the Blade chart read$series['items_created']['_total'], which was always empty. Job now also writes a_totalaggregate 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}/callbackwas missing a throttle; now appliesthrottle:10,1. (audit C2-38) IngestPayload.swiftdocstring corrected. Opening comment said "url and body are both optional — at least one must be present"; server validation requiresurlunconditionally. Docstring updated to match actual contract. (audit C3-12)StashClient.swiftdocstring updated to three endpoints. "two endpoints" claim omittedGET /api/me/subscriptionadded in K10. (audit STASHKIT-2026-05-18-019)- Sitemap
/changelogentry now has<lastmod>. Added<lastmod>2026-05-12</lastmod>to the/changelogURL block. (audit HOMEPAGE-SITEMAP-002) - Chrome ext: dead
headersToObjectexport removed.lib/config.jsexportedheadersToObjectbut 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
#pricingcomment removed from items index. Block comment initems/index.blade.phpreferencedstash2self.com/#pricing; link has routed to/upgradesince 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, andAPPLE_PRIVATE_KEYplaceholder entries added. Required for dynamic JWT generation (alternative to the pre-generatedAPPLE_CLIENT_SECRET). (audit AUTH-2026-05-18-011) - Apple block in
config/services.phpexposes key/team/private-key. Addedkey_id,team_id,private_keyconfig 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/upgradepage. 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, andextra_headerswere sent on every ingest but the server reads these fromuser_settingsexclusively and silently discards payload values. Aligns Chrome ext with the iOS v2.1.2 wire-contract fix (STASHKIT-002). Chrome ext manifest bumped to1.5.2. (audit C3-8) - Chrome ext popup paywall reads server-supplied item counts. The 402 cap response includes
item_countanditem_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 readschrome.runtime.getManifest().versiondynamically. (audit C3-8)
Added
- Pro affordance on free-tier
/settingsAI 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,disabledstate, and a caption linking to/upgrade. Server-side gating unchanged. (audit walkthrough finding 3) - Custom 404 page on marketing homepage.
/404.phpadded using the existing_legal_layout.phpscaffold;.htaccesswired withErrorDocument 404 /404.php. (audit HOMEPAGE-1) - Real CHANGELOG.md rendered on marketing
/changelog.changelog.phpreplaced the "Coming soon" stub with a pure-PHP Markdown renderer reading_legal_pages/changelog.md. The deploy script now copiesCHANGELOG.md→homepage/_legal_pages/changelog.mdon every homepage deploy. (audit HOMEPAGE-2) MAIL_SUPPORT_ADDRESSconfig key. Added toconfig/mail.phpand.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; includeSubDomainsuncommented in.htaccess(withoutpreload— 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
lastmodrefreshed 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.webmanifestfrom 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.mdandCLAUDE_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
502response (row inserted, Resend failed) emits thestatusfield required by iOSIngestResponse, soStashError.resendFailedactually fires. Without it, the partial-success catch inShareRootViewandConfigViewModelwas 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 /settingsnow gated byblock.during.impersonation. Previously, an admin acting under impersonation could change the target user'sdestination_emailto an attacker mailbox and exfiltrate every subsequent bookmark email. Token routes already had the middleware; settings did not. Regression test added inImpersonationTest. (audit SETTINGS-TOKENS-1) - Security — user delete now cleans up
personal_access_tokens. Sanctum'spersonal_access_tokensuses polymorphicmorphs()with no FK and nodeleting()observer, so a user deletion viaUserDeleteControllerorphaned every issued token. Now explicitly calls$user->tokens()->delete()inside the transaction, with a regression test inUserDeleteTest. (audit ADMIN-OPS-1) - AI surface — chat + MCP + weekly digest now respect
Item::scopeVisible. Moderator-hidden items previously leaked through chat-timeSearchBookmarksTool, the MCPSearchBookmarksTool, and theSendWeeklyDigestJobthisWeek / olderSample / resurfaced queries — none of which chained->visible(). ItemsController::index already excluded them; the AI surfaces now match. Regression test inStashMcpServerTest. (audit AI-001 / AI-002 / C3-5) - Admin stats — auth-attempts ApexChart now renders.
RollupDailyStatsJobwrites namespaced'succeeded:true'/'succeeded:false'dimension keys (matching the'k:v'shape of every other dimension), butstats.blade.phpwas 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/upgradeBlade route on the Laravel app surfaces a brand-on landing page with iOS upgrade guidance; Chrome ext fallback updated. Manifest version bumped to1.5.1. (audit CHROME-1) - Chrome ext over-permissioning closed.
manifest.jsondeclared thescriptingpermission butchrome.scriptingis never used. Removing it shrinks the install-prompt blast radius. (audit CHROME-2) - iOS wire-contract cleanup —
IngestPayloadno longer sends ignored fields.subject_preface/subject_suffix/extra_headerswere sent on every ingest but the server sources them fromuser_settingsexclusively, silently discarding payload values. Parallel to the v1.3.0destination_emailremoval. Local share-sheet preview still usesStashConfig.subjectPreface/Suffixfor the chip. (audit STASHKIT-002)
Changed
MAIL_FROM_NAMEaligned to "Stash2Self". Locked-decision text inCLAUDE.mdandCLAUDE_DEPLOYMENT.mdwas stale at "Stash" from before the rebrand; production.env.examplealready used "Stash2Self". (audit MAIL-001)CLAUDE_*.mdtest counts truth-up. 103/246 → 227/644 acrossCLAUDE.md,CLAUDE_TESTING.md,CLAUDE_HISTORY.md. (audit INFRA-DOCS-3)- Deploy script doc —
--no-interaction→--compact. PHPUnit 12 rejects the Laravel-only--no-interactionflag; the actual deploy gate already uses--compact(audit-2026-05-01).CLAUDE_DEPLOYMENT.md+CLAUDE_TESTING.mdupdated to match. (audit INFRA-DOCS-1) - Prod env table truth-up.
QUEUE_CONNECTION=database→syncto match the actual.env.exampledefault flipped in audit-2026-05-01 INFRA-5. (audit INFRA-DOCS-2) .env.exampleadds 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.phpreads 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)
ChatControllerdocblock truth-up. Removed misleading claim of client-side history persistence; chat is one-prompt-at-a-time. (audit AI-003)
Added
- Public
/upgradeBlade landing page for Chrome ext + iOS paywall fallback whenstash://upgradedeep-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-spmv5.70.0 +RevenueCatUIadded via SwiftPM instash-ios/project.yml.StashApp.swiftconfiguresPurchaseswith the RevenueCat production public key, presentsPaywallViewfrom thedefaultoffering, and wires theCustomerCenterViewfor paid-user manage-subscription / cancel / refund flows.SubscriptionStore.swiftis now a thin observable wrapper that stays server-authoritative for entitlement state (isSubscribedreads from the backend'susers.subscription_activeviaGET /api/me/subscription) while delegating purchase / paywall UI / Customer Center to RevenueCat. After every server refresh, the store callsPurchases.logIn(userId)so RC'sapp_user_idmatches whatRevenueCatController::__invokecasts to(int)andUser::finds. /api/me/subscriptionexposesuser_id. Stable string form of the backend'susers.idprimary key. Threaded throughSubscriptionStatus.swiftso the iOS RevenueCatlogIn(userId)call has a value to use without a separate/api/meround-trip. TwoMeSubscriptionTestcases updated; suite stays green.SubscriptionRowview inConfigView.swift. Free-tier card shows item-cap progress + "Upgrade to Pro" button (callssubscriptionStore.presentPaywall()); paid-tier shows "Stash2Self Pro" badge + "Manage" button (callssubscriptionStore.presentCustomerCenter()).- TestFlight build 1.0.0 (1) uploaded. Build id
e44b00af-a110-4b1f-b0dc-42462f8e9565,internalBuildState: IN_BETA_TESTING,processingState: VALID. Distributed to a newInternal Testersgroup (id3feb184e-…) 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.mdreference 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.mdexpanded 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 includesxcodegen generate && xcodebuild buildfor iOS +jqChrome-ext manifest validity check. Lessons-learned section picks up four new entries:laravel/mcprequire-not-require-dev, iOS Info.plist regenerated by xcodegen, TestFlight invitation-acceptance gate,asc builds add-groupssilent no-op.
Changed
stash-ios/Stash/App/StashApp.swiftis now app-entry-and-paywall surface, not just app entry. Two.sheet(isPresented:)modifiers bind tosubscriptionStore.isPresentingPaywall/isPresentingCustomerCenterand present the RevenueCat-supplied views.SubscriptionController::showreturnsuser_idas the first field of the JSON envelope. Cache-Control header unchanged.docs/OPERATOR_TASKS.mdrewritten 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.mdstatus refreshed for v2.1.1. Tests badge103 → 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✅ shippedbanner 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 indocs/OPERATOR_TASKS.md: wrap the export call inPATH="/usr/bin:/bin:/usr/sbin:/sbin"so openrsync calls itself consistently. Without this, the export fails atIDEDistributionCreateIPAStepwithCopy 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 exportreportedtesters: 0until acceptance — that count IS the acceptance check. Re-issued viaasc testflight testers invite(new invitation id44be7354-…). 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-groupsreturned"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_METADATAuntil submission. Apple requires a 1024×1024 screenshot for the subscription product before the App Store reviewer ticket can proceed; not blocking sandbox testing. Seedocs/OPERATOR_TASKS.md§9 step 6. - Sandbox tester ≠ TestFlight tester. Documented in
docs/OPERATOR_TASKS.md§3d: sandbox accountstash2self@randomsynergy.comis 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 forEntitlementSnapshotJobto 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/aiv0.6.5 + Anthropic Claude Haiku 4.5 for all four features. Auto-tag (eager on ingest, structured output toitems.ai_tags), auto-summary (lazy on first view, cached onitems.ai_summary), weekly digest (Sunday 09:00 UTC fan-out per active subscriber withai_weekly_digest=true), chat surface at/chat(tool- based grounding via the user's stash, no training-data hallucination), and an MCP runtime atPOST /mcpfor external clients (Claude Desktop, Claude Code) over Sanctum bearer with thereadability. AI calls short-circuit cleanly on failure (stampai_processed_at, log, no queue-storm). - Phase 4 client toggles.
ai_auto_tag/ai_auto_summary/ai_weekly_digestcheckboxes on/settings, default-on for paid users. Tag chips render per-row on the items index whenai_tagsis 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_actionstargeting 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/securitydashboards. 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 onauth_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 writeadmin_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 overGET /api/me/subscription, refreshes on launch + scenePhase=.active) +SubscriptionStatusdecoder. 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 tostash2self.com/#pricingwhen 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 akind: 'paywalled'result tag with web-URL fallback for the iOS-onlystash://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 legacystash.randomsynergy.xyzbackend 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 becomecom.stash2self.app(main) andcom.stash2self.app.share(Share Extension). App Group becomesgroup.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::indexandListJsonControllerfilter throughItem::scopeVisible. Moderator-hidden items (Phase M tier 5) render nowhere user-facing.ItemPolicy::viewreturnsdenyAsNotFoundeven for the owner of a hidden item — preserves the per-user 404 contract while honoring the moderation soft-hide.AutoTagItemJobruns the realAutoTagAgentinstead of the Phase 4 foundation stub. Failures swallow cleanly + stampai_processed_at(idempotent retries already short-circuit). Tests rewritten to cover the real call viaAi::fakeAgent().ItemsController::showlazily dispatchesAutoSummaryItemJobwhen the bookmark has no cached summary + the user is on the paid tier withai_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 manifestshort_name, Chrome extension manifestname/short_name/ options page heading, Chrome extension popup misconfigured-state heading,chrome.notificationstitles for the context-menu surfaces, iOSCFBundleDisplayName(3 places viaproject.yml+ Info.plists), iOSStashErroruser-facing strings. Internal class names (StashClient,StashKit,StashColors,StashMail) and the repo dir name remain — those are code identifiers, not the product name. UserSettingsController::updateaccepts 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 toanthropicviaAI_DEFAULT_PROVIDERenv. 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_KEYlands in.env. All four agents short-circuit gracefully without it; the scaffolding works in tests viaAi::fakeAgent()regardless. - Phase J — darktower decommission — permanently skipped per user direction. The legacy stack at
stash.randomsynergy.xyzkeeps running indefinitely as a personal stash. Out of scope for further development. - Cron is wired as of this release (
* * * * * php artisan schedule:runconfirmed in cPanel). All scheduled tasks fire — daily prunes plus the newRollupDailyStatsJob(00:05), weeklyDispatchWeeklyDigestsJob(Sunday 09:00 UTC), andEntitlementSnapshotJob(03:15, no-op untilREVENUECAT_API_KEYconfigured). - iOS
Info.plistfiles are gitignored becausexcodegen generaterebuilds them fromproject.yml. The bundle-display-name change in this release ships viaproject.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 atstash.randomsynergy.xyzis 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)inApp\Providers\AppServiceProvider::bootto fit cPanel's MariaDB utf8mb4 1000-byte index-key cap. Pulse migration'spulse_aggregatestypeandaggregatecolumns trimmed to VARCHAR(64) for the same reason. - Auth: three shared secrets → per-user accounts.
BEARER_TOKEN,ADMIN_PASSWORD, andaccess_passwordall 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_adminboolean 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 athttps://app.stash2self.com/(subdomain, symlinks to~/laravel-app/public). DNS + AutoSSL configured. - Sender:
noreply@stash2self.com(Resend-verified) replacesstash-noreply@randomsynergy.xyz. Live transport verified end-to-end. - Per-user destination email now lives in
user_settings.destination_emailand falls back tousers.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 Groupgroup.com.stash2self.app. Team IDCYJSFXX493unchanged. Earlierxyz.randomsynergy.stashnamespace retired; one-time per-device re-pair on update (combined with Sanctum-token rotation). stash-muteddesign token darkened from#6B6862to#5A5853on the light theme — 4.2:1 → 5.4:1 contrast on light backgrounds, WCAG AA pass for small text. Single source of truth intailwind.config.js+ iOSStashColors.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 isolation —
App\Policies\ItemPolicywithResponse::denyAsNotFound()for non-owners (cross-user access is 404, never 403 — no existence leak). Writes via parent relationships ($user->items()->create(...));user_idremoved from#[Fillable]on Item / OauthProvider / UserSetting.is_root_adminandemail_verified_atsimilarly not fillable on User (set viaforceFill). App\Http\Resources\ItemResourceas the single source of truth for Item public JSON shape.public static $wrap = nullpreserves the legacy{items: [...]}/ bare-object contract that iOS / PWA / Chrome ext consume.App\Listeners\LogAuthAttemptwrites toauth_attemptson every login (success + failure). Single-argEvent::listen([Class, 'handle'])registration — Laravel auto-discovers the union-typedhandle(Login|Failed).App\Mail\StashItemMailcomposes subject as{preface}{title-or-url}{suffix}fromuser_settings.headers()re-validatesextra_headersviaApp\Rules\SafeHttpHeadersbefore applying.IngestControllercaptures$sent->getMessageId()toitems.resend_id.- PHP-backed enums —
App\Enums\{ResendStatus, ItemSource, OauthProviderName}replace magic strings onItem.source,Item.resend_status,OauthProvider.provider. Eloquent casts handle round-trip. - Sanctum tokens — 1-year sliding expiry (
config/sanctum.phpexpiration: 525600). Dailysanctum:prune-expired --hours=24removes expired rows.oauth_providers.rawwas dropped — was storing access tokens unencrypted with no consumer. - Rate limiting —
POST /api/ingest60/min/user;POST /register10/min/IP;POST /forgot-password5/min/IP. Login already throttled by Breeze (5/min/email+IP). /healthzdeep 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:imageat 1200×630 / 105 KB JPEG withog: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.phpshell mirrors the homepage's brand + dark/light toggle..htaccessrewrite for clean URLs. - Custom error pages at
errors/{404,403,419,500,503}.blade.phpwitherrors/minimal.blade.phpshared layout. /admin/telescope+/admin/pulse— bothis_root_admin-gated. Telescope production filter records only exceptions / failed requests / failed jobs / scheduled tasks.password,password_confirmation,current_password,token,remember_token, andAuthorizationmasked from request bodies.- Daily scheduled prunes in
routes/console.php:auth_attempts(>30d), Telescope (--hours=168), Pulse (trim), Sanctum tokens (--hours=24), sessions (>30d). MustVerifyEmailcontract onUser—verifiedmiddleware 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 (appLaravel +homepagestatic), per-target subcommands (all/file/dir/status/help),php artisan testgate before app deploys (--skip-teststo bypass), composer install + artisan migrate + cache:warm + queue:restart on the remote,/healthzauto-smoke. Bash 3.2 compatible (nolocal -nnameref). - Six-agent expert audit of the full migration surface, in five waves of fixes (commits
ac1963a→03d9dcb): - Wave 1 — security hardening (Telescope masking, ingest / register / forgot-password rate limits, OAuth callback session regenerate +
wasRecentlyCreatedguard 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 gotfor=/id=association, custom error templates). - Wave 4 — perf + test gaps (
/healthzno-leak posture, items index singledata-searchattribute, 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 sha56dea55for archival access.xyz.randomsynergy.stashKeychain access group / bundle prefix — replaced bycom.stash2self.appfamily.
Security
- OAuth callback auto-verifies
email_verified_atONLY 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 guard —
session()->regenerate()afterAuth::loginin OAuth callback. - Telescope production filter masks
password,password_confirmation,current_password,token,remember_token, and theAuthorizationheader from request bodies. /healthzreturns 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=databaseis set but no worker runs; current code uses synchronousMail::to()->send()so the mismatch is harmless today. Phase 4 features that call->queue()will need eithersyncdriver 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…d77d5instead of the floating:latesttag. 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=falseadded as a belt-and-suspenders label.
Added
- Stash2Self domain configuration on cPanel —
stash2self.com(main, Force HTTPS on) →~/public_htmlfor a static marketing homepage;app.stash2self.com(subdomain) →~/app.stash2self.com(will become a symlink to~/laravel-app/publicin plan phase G). DNS + AutoSSL set up. - Unified deploy script at _helpers/deploy.sh — rsync-over-SSH with two targets (
appfor the future Laravel project,homepagefor the static site) and per-target subcommands (all/file/dir/status/help). Theapptarget gates onphp artisan test(skippable with--skip-tests), runs composer install + artisan migrate + cache:warm + queue:restart on the remote, and auto-smokes/healthzafter deploy. Replaces the earlierstash-web/scripts/deploy.shwhich 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 installed —
ascCLI (Homebrew) plus the 22-skillapp-store-connect-cli-skillspack 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 (
.envquoting,install:apipath,config()notenv()in healthz, controller middleware syntax, missingRequest $request), G-series gaps (iOS endpoint URLs across 4 files, stub view ordering,extra_headersvalidation rule, transaction-wrapped data import), and S-series doc touches (Chrome exthost_permissionsdiff, 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.appnamespace — 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 tostash-ios/andstash-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+Enterfrom 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 atchrome://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/ingestfor 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_permissionsdeclares the Stash backend only; MV3 SWfetch()is exempt from CORS preflight on those origins. No backend change required.activeTab(vstabs) — 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_settingsand achrome.* → 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 editingmanifest.jsonand 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.jsonandGET /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-revalidateso 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
/logoutfetches and302 → /accessresponses on/list.jsonor/item/<id>.json; either trigger purgesstash-data-v1andstash-favicon-v1. Static cache preserved (no PII). Belt-and-suspenderspostMessage({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_atin the JSON response. - Alpine factories at /static/stash-list.js and /static/stash-item.js.
Changed
views/list.phpandviews/item.phpare 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
/\insanitizeNext. Browsers normalize backslash into the authority portion, so/access?next=/\attacker.comwould produce a scheme-relative redirect. The existing//check didn't cover it. Now rejected alongside//, CR/LF, and NUL. - Privacy:
/item/:idwas showingdestination_emailto 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$isAdminlike the rest of the status block. StashClient.perform()was replacing the real URLError with a synthetic.unknownon any non-URLError.ShareRootViewbranches 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 (assertionFailureis a no-op outside Debug). Switched toNSLogso a failed write surfaces in the device log.
Changed
IngestPayloadno longer sendsdestinationEmail. 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 readsStashConfig.destinationEmaildirectly (not from the payload).IngestResponse.resend_idrenamed toresendIdwith explicitCodingKey = "resend_id", matching theIngestPayloadcamelCase + 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.reddirectly. - Dead asset colorsets —
Stash/Assets.xcassets/StashAccent.colorset,StashSuccess.colorset, and bothAccentColor.colorsets (inStash/andStashShare/). All were unused; UI colors come fromStashColors.swiftas the single source of truth, and.tint()is applied explicitly on every root view. Kept:StashBackground.colorset(referenced by the launch-screen inInfo.plist) and bothAppIcon.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 to127.0.0.1; actual default has been0.0.0.0since 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_settingscontract written down in CLAUDE.md. Any new form inviews/_settings_panel.phpmust 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 byxcodegen. HANDOVER_CLAUDE_DESIGN.mdgets 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.exampledemotesINGEST_TOKENfrom an equal-tier secret to a commented-out legacy fallback. New deploys should use per-roleBEARER_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-IPcan 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
#C89B3Caccent, palette per HANDOVER_CLAUDE_DESIGN.md §4). Delivered as 1024×1024 PNG intostash-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: StashShareto the Stash target's dependencies in stash-ios/project.yml; XcodeGen now emits aPBXCopyFilesBuildPhase(dstSubfolderSpec=13/ PlugIns) so the.appexlands atStash.app/PlugIns/StashShare.appexon 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.ShareRootViewfires it in a background.taskwhen 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
IngestPayloadis idiomatic Swift again. Stored properties renamed to camelCase;CodingKeyspins the wire format to snake_case explicitly. A future rename can no longer silently break the/ingestcontract.StashClientnormalizes the endpoint URL.URLComponentsnow strips any path/query/fragment from the user-supplied endpoint before appending/ingestor/healthz. Pastinghttps://stash.randomsynergy.xyz/appno longer produces wrong/app/ingestrequests.- 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. /ingesttimeout bumped to 30 s (iOS), defaultsourcevalue is now"share_extension".
Fixed
- Deleting an item from the list (or the item detail page) opened the admin settings modal.
flashRedirect()was hardcodingsettings=openinto every post-action redirect, andviews/list.phpreads$_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=1hidden input. Delete forms don't set the flag, so the post-delete redirect is plain and the list renders without the modal. /settingsand/accesswere throwing a favicon 404 on every load — both pages were missing the<link rel="icon">block. Added the same five-line block used bylist.phpanditem.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
.gitignoreexcluding.DS_Store,.claude/, and.playwright-mcp/so MCP-generated test artifacts can't enter future commits. Subfoldersstash-web/andstash-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'sx-databecausejson_encode()output was embedded raw into a double-quoted HTML attribute, so the first"inside the JSON prematurely closed the attribute. Browser received a truncatedx-data, Alpine failed to parse, everyx-show="!q || …"then threwq is not defined, and Alpine hides elements whosex-showexpression errors. Wrapped thejson_encode()output inhtmlspecialchars(via the existing$eschelper) so"gets encoded as". Confirmed with Playwright: 0 console errors, all 8 items render.
[1.1.1] - 2026-04-24
Changed
docker-compose.ymlport binding reverted to0.0.0.0default. 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's127.0.0.1. The proper fix — shared Docker network withNPM → 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 forbearer_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
/accessand/settingslogin: 10 failed attempts per 15-min rolling window. New lib/RateLimit.php with an atomiccheckAndRecordFailure()variant that wraps check + insert in aBEGIN IMMEDIATEtransaction so parallel bursts can't slip past the pre-check. - Schema migration gate via
PRAGMA user_versionin 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
.backupcron with 14-day retention and atrapthat 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
/healthzprobe: 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_emailis now server-locked; caller override is ignored. (Previously any bearer-token holder could relay email from the verified Resend sender to arbitrary recipients.)extra_headerssanitized: CR/LF/NUL stripped, keys must match[A-Za-z0-9\-]+, values capped at 500 chars, max 10 entries.subject_preface/subject_suffix/titlestripped 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_passwordas a second factor. A stolen admin cookie alone can no longer rotate iOS credentials. - Cookies changed from
SameSite=LaxtoSameSite=Strict, andSecureis now unconditional (was tied toX-Forwarded-Proto). Trade-off: clicking an email "view in Stash" link requires re-signing-in on the access gate. Eliminates the CSRF class and theX-Forwarded-Proto-spoof vector. - iOS
/ingesttimeout 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.loadItembounded at 2 s via awithCheckedContinuation+ GCD timer with a first-wins flag, so a hung host provider cannot freeze the share sheet. ConfigViewModel.scheduleSaveTask now@MainActor-isolated to avoid aUserDefaultswrite race afterTask.sleepresumes.docker-compose.yml: port binding defaults to127.0.0.1:8042(was0.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_PASSWORDfail-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 intry/catchfor 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 anhttp/httpsscheme 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-cloakon<li>elements kept them atdisplay: none !importantuntil 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.comwhen destination email was blank. - Dead code removed from StashClient.swift (unused failable init).
Security
- CSRF class eliminated via
SameSite=Strictcookies and a cleaner stolen-cookie blast-radius (bearer rotation now 2FA-gated). - Phishing-via-Stash's-verified-sender closed (
destination_emailoverride removed). - Header injection closed (
extra_headerskey/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
- Initial release. iOS Share Extension, PHP/SQLite backend on RanSynSrv, Resend email integration. See _ref/Stash-iOS_Init_files/HANDOVER_CLAUDE_CODE.md for the baseline spec.
Questions about this page? Email support@stash2self.com.