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