stash2self

Changelog

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

Last updated 2026-05-18

[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.