From 6c0de4357770e415b82cf6241459ef1350bf9288 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Tue, 29 Sep 2026 21:28:40 +0200 Subject: [PATCH 1/3] Stop syncing for an Enterprise-tier account with no assigned seat SYNC_ENTITLED_TIERS included 'enterprise', so shouldSync() returned true for any account billed at that tier, including the owner before they ever assigned themselves a seat - disagreeing with the server, which now requires enterprise_owner_id to actually be set (see the same-day cloud/ and website/ commits). Now just ['pro']; enterpriseOwnerId is what grants entitlement, tier alone does not. Checked: tsc --noEmit, npm run test (250 passing, 2 new). --- CHANGELOG.md | 7 +++++ src/lib/services/__tests__/cloud-sync.test.ts | 28 +++++++++++++++++++ src/lib/services/cloud-sync.ts | 13 +++++++-- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be17930..896d460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ popover, not for the person who wrote the commit. ## [Unreleased] +### Fixed +- **An Enterprise-billed account no longer syncs on its own, without an assigned seat.** + The Enterprise tier used to turn on Cloud Sync by itself, so the account a subscription is + billed to - the owner - got full access before ever assigning themselves one of their own + seats. Cloud Sync now needs a Pro subscription or an actually assigned Enterprise seat, and + an owner gets one the same way a teammate does + ## [1.9.0] - 2026-09-27 ### Added diff --git a/src/lib/services/__tests__/cloud-sync.test.ts b/src/lib/services/__tests__/cloud-sync.test.ts index 870ed13..4774dfd 100644 --- a/src/lib/services/__tests__/cloud-sync.test.ts +++ b/src/lib/services/__tests__/cloud-sync.test.ts @@ -202,6 +202,34 @@ describe('CloudSyncService', () => { expect(adapter.syncStashesApi).not.toHaveBeenCalled(); }); + + it('does not sync for enterprise tier alone - that only names who is billed, not who has a seat', async () => { + const adapter = createAdapter({ + fetchCloudAccount: vi.fn().mockResolvedValue(cloudConfig({ subscriptionTier: 'enterprise' })), + }); + const service = new CloudSyncService(adapter); + + await service.initialize(settingsWith(cloudConfig({ subscriptionTier: 'enterprise' }))); + await flushPromises(); + + expect(adapter.syncStashesApi).not.toHaveBeenCalled(); + }); + + it('syncs once a seat is consumed, even for the owner pointed at their own id', async () => { + const adapter = createAdapter({ + fetchCloudAccount: vi.fn().mockResolvedValue( + cloudConfig({ subscriptionTier: 'enterprise', enterpriseOwnerId: 'user-1' }), + ), + }); + const service = new CloudSyncService(adapter); + + await service.initialize( + settingsWith(cloudConfig({ subscriptionTier: 'enterprise', enterpriseOwnerId: 'user-1' })), + ); + await flushPromises(); + + expect(adapter.syncStashesApi).toHaveBeenCalled(); + }); }); describe('outgoing payload', () => { diff --git a/src/lib/services/cloud-sync.ts b/src/lib/services/cloud-sync.ts index 376b015..55b9717 100644 --- a/src/lib/services/cloud-sync.ts +++ b/src/lib/services/cloud-sync.ts @@ -187,8 +187,17 @@ interface ContextSyncResponse { partial?: boolean; } -/** Subscription tiers entitled to cloud sync */ -const SYNC_ENTITLED_TIERS = ['pro', 'enterprise']; +/** + * Subscription tiers entitled to cloud sync on their own. + * + * Deliberately excludes 'enterprise': that tier only names who an enterprise + * subscription is billed to, not who has consumed a seat. An owner who has not accepted a + * seat - including one of their own - has no entitlement either, same as any teammate; see + * `enterpriseOwnerId` below, which is what actually grants it. Matches + * `has_cloud_storage` in `cloud/src/quota.rs` and the check in the website's + * `AccountDashboard.svelte` - all three have to move together. + */ +const SYNC_ENTITLED_TIERS = ['pro']; /** * Does the server know about attachments this device does not have? From a0abf12d10ee50f9311c2b994dcc6f6baec28d97 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Thu, 1 Oct 2026 08:36:14 +0200 Subject: [PATCH 2/3] Read and write every date in the app through Temporal instead of Date and chrono The frontend now gets every date and time from src/lib/utils/time.ts, which takes Temporal from temporal-polyfill as a value. Without that, WebView2 would run native Temporal while WKWebView and WebKitGTK ran the polyfill. The backend gets them from src-tauri/src/time.rs on temporal_rs, and chrono, SystemTime and UNIX_EPOCH are gone. cargo tree finds no path to chrono any more. Both sides now write one text format: RFC 3339 in UTC, always nine fractional digits, always Z. Before this, the same columns held chrono's +00:00 with up to nine digits from Rust and Date's Z with three from the frontend. SQLite compares those columns as text, so the completed-stash cleanup and the newest-first export could misorder two rows within the same second. The DB writers normalise whatever they are handed, and a one-time pass, recorded in PRAGMA user_version, rewrites existing rows and keeps any value it cannot read. The legacy toLocaleString() heading dates in old archives were parsed by a chrono format list. That is now a small hand-written parser, tested against every form the list accepted. The export dialog took its file name's date from UTC and its time from the local clock, so exports near midnight were named a day off. Both now come from the local calendar (CHANGELOG entry). getRelativeTime keeps its signature and i18n keys, so no call site changed. Every read of "now" goes through time.ts, so the screenshot demo freezes it with setClock instead of replacing globalThis.Date. npm run check:temporal fails on any Date, a stray polyfill import, chrono or SystemTime, and runs in the frontend test job. Checked: 268 vitest tests, 178 cargo tests with clippy -D warnings and fmt clean, check:temporal against deliberate violations in both languages, a vite build, and the queue screenshot, which still reads "6 minutes ago" under the frozen clock. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test.yml | 5 + CHANGELOG.md | 4 + TESTING.md | 9 +- package-lock.json | 29 ++- package.json | 4 +- screenshots/capture.mjs | 4 +- screenshots/demo.ts | 24 +- screenshots/fixtures.ts | 9 +- screenshots/mock-backend.ts | 7 +- scripts/check-temporal.mjs | 97 ++++++++ src-tauri/Cargo.lock | 202 ++++++++++++++--- src-tauri/Cargo.toml | 4 +- src-tauri/src/contexts.rs | 5 +- src-tauri/src/db.rs | 189 +++++++++++++--- src-tauri/src/lib.rs | 1 + src-tauri/src/settings.rs | 5 +- src-tauri/src/stashes.rs | 10 +- src-tauri/src/sync.rs | 4 +- src-tauri/src/time.rs | 208 ++++++++++++++++++ src-tauri/src/transfer.rs | 180 ++++++++++----- src/App.svelte | 9 +- src/lib/components/ContextManager.svelte | 9 +- src/lib/components/ContextSwitcher.svelte | 5 +- src/lib/components/Editor.svelte | 9 +- src/lib/components/ExportDialog.svelte | 18 +- src/lib/components/Header.svelte | 3 +- src/lib/components/ImportDialog.svelte | 9 +- src/lib/components/Queue.svelte | 7 +- src/lib/components/Settings.svelte | 3 +- src/lib/services/__tests__/cloud-sync.test.ts | 15 +- src/lib/services/cloud-sync.ts | 34 +-- src/lib/stores/attachment-sync.svelte.ts | 5 +- src/lib/stores/updater.svelte.ts | 7 +- src/lib/utils/__tests__/date.test.ts | 41 ++-- src/lib/utils/__tests__/time.test.ts | 94 ++++++++ src/lib/utils/date.ts | 22 +- src/lib/utils/time.ts | 135 ++++++++++++ 37 files changed, 1172 insertions(+), 253 deletions(-) create mode 100644 scripts/check-temporal.mjs create mode 100644 src-tauri/src/time.rs create mode 100644 src/lib/utils/__tests__/time.test.ts create mode 100644 src/lib/utils/time.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0dea2a7..a725714 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,6 +80,11 @@ jobs: - name: Install dependencies run: npm ci + # Not a test, but cheap and with nothing else to gate it: a stray `Date` passes every + # test that does not happen to compare two timestamps as strings. + - name: Check Temporal is the only date API + run: npm run check:temporal + # Split by OS rather than run both: `test:coverage` is `vitest run --coverage`, the # same fourteen files end to end, so ubuntu was running the whole suite twice. - name: Run tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 896d460..f06b35a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ popover, not for the person who wrote the commit. billed to - the owner - got full access before ever assigning themselves one of their own seats. Cloud Sync now needs a Pro subscription or an actually assigned Enterprise seat, and an owner gets one the same way a teammate does +- **An export's suggested file name carries the right date around midnight.** The name took + its date from UTC and its time from your own clock, so an export made in the hours between + your midnight and UTC's was named with the day before or the day after. Both now come from + your own calendar ## [1.9.0] - 2026-09-27 diff --git a/TESTING.md b/TESTING.md index 9cfb84c..82ab41a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -199,7 +199,7 @@ mod tests { content: "test content".to_string(), attachments: vec![], files: vec![], - created_at: chrono::Utc::now().to_rfc3339(), + created_at: crate::time::now_iso(), context_id: "default".to_string(), completed: false, completed_at: None, @@ -363,6 +363,13 @@ jobs: expect(formattedDate).toContain('2025'); ``` + Fix "now" with `setClock` from `$lib/utils/time`, and restore it with `setClock(null)` + in `afterEach`. All app code reads the clock through that module, never `Date` - + `npm run check:temporal` fails on a `Date` anywhere, tests included. Build timestamps + with `fromEpochMs` or `toCanonical` so they have the same nine-digit form the app + writes. `vi.useFakeTimers()` still moves the clock as well, because the polyfill + reads the time through it. + 2. **File API Mocking**: jsdom doesn't fully support File APIs ```typescript // Mock arrayBuffer for File objects in tests diff --git a/package-lock.json b/package-lock.json index 16d17ba..29935a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stashpad", - "version": "1.8.7", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stashpad", - "version": "1.8.7", + "version": "1.9.0", "license": "AGPL-3.0", "dependencies": { "@tailwindcss/typography": "^0.5.19", @@ -29,7 +29,8 @@ "marked-highlight": "^2.2.3", "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "temporal-polyfill": "^1.0.5" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -4552,6 +4553,28 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/temporal-polyfill": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-1.0.5.tgz", + "integrity": "sha512-+H/mmY74i6wXCMcjIhGuSnODcyZnc4eois2myhOyX0UqRAKNXQY5m9iB1en5jQ/n4SXtjLXo1ElBI5bmn8tZwQ==", + "license": "MIT", + "dependencies": { + "temporal-spec": "1.0.1", + "temporal-utils": "1.0.3" + } + }, + "node_modules/temporal-spec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-1.0.1.tgz", + "integrity": "sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==", + "license": "Apache-2.0" + }, + "node_modules/temporal-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/temporal-utils/-/temporal-utils-1.0.3.tgz", + "integrity": "sha512-9jbTSX3HvkOWvDpNpFzwX/d1mkCrgQgwLmVTOEiuocZGKHsrQJtttGZFmybJ2zYP5zCSZuU5ZyeS8+eOEPABiQ==", + "license": "MIT" + }, "node_modules/timers-ext": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", diff --git a/package.json b/package.json index e4f220f..f32bd0c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "scripts": { "sync-version": "node scripts/sync-version.mjs", "check:changelog": "node scripts/check-changelog.mjs", + "check:temporal": "node scripts/check-temporal.mjs", "predev": "npm run sync-version", "dev": "tauri dev", "vite:dev": "vite", @@ -85,6 +86,7 @@ "marked-highlight": "^2.2.3", "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "temporal-polyfill": "^1.0.5" } } diff --git a/screenshots/capture.mjs b/screenshots/capture.mjs index 89c8dcc..717f041 100644 --- a/screenshots/capture.mjs +++ b/screenshots/capture.mjs @@ -262,8 +262,8 @@ async function startDevServer() { child.stderr.on('data', (d) => process.stderr.write(d)); // Cold, Vite spends most of a minute pre-bundling before it answers. - const deadline = Date.now() + 180_000; - while (Date.now() < deadline) { + const deadline = performance.now() + 180_000; + while (performance.now() < deadline) { if (await demoPageIsUp()) return child; if (child.exitCode !== null) throw new Error(`the dev server exited (${child.exitCode})`); await new Promise((r) => setTimeout(r, 500)); diff --git a/screenshots/demo.ts b/screenshots/demo.ts index 7f5c5d2..ea66355 100644 --- a/screenshots/demo.ts +++ b/screenshots/demo.ts @@ -44,26 +44,16 @@ installMockBackend({ settings }); /** * Freeze the clock at the fixtures' instant. * - * Relative timestamps ("6m ago") are computed against `Date.now()`, so without this the - * same scene captured twice produces two different images and every release ships a - * screenshot diff that means nothing. Only the reading of "now" is frozen - timers still - * run, or the app would never finish starting. + * Relative timestamps ("6m ago") are computed against the current time, so without this + * the same scene captured twice produces two different images and every release ships a + * screenshot diff that means nothing. Every reading of "now" in the app goes through + * `$lib/utils/time`, so fixing its clock is enough. Timers still run, or the app would + * never finish starting. */ async function freezeClock(): Promise { const { NOW } = await import('./fixtures'); - const RealDate = Date; - const fixed = NOW.getTime(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const Frozen: any = function (this: unknown, ...args: unknown[]) { - return args.length === 0 - ? new RealDate(fixed) - : new (RealDate as unknown as new (...a: unknown[]) => Date)(...args); - }; - Frozen.prototype = RealDate.prototype; - Frozen.now = () => fixed; - Frozen.parse = RealDate.parse; - Frozen.UTC = RealDate.UTC; - globalThis.Date = Frozen; + const { setClock } = await import('../src/lib/utils/time'); + setClock(() => NOW); } await freezeClock(); diff --git a/screenshots/fixtures.ts b/screenshots/fixtures.ts index 7315c34..951dfc8 100644 --- a/screenshots/fixtures.ts +++ b/screenshots/fixtures.ts @@ -18,19 +18,20 @@ * fixture: these end up on the public website, and a screenshot is the easiest place * to leak a customer name or a home directory without noticing. * - * Timestamps are relative to a fixed instant (`NOW`) rather than to `Date.now()`, so a + * Timestamps are relative to a fixed instant (`NOW`) rather than to the real clock, so a * capture taken today and one taken next month produce the same images. The app renders * "2h ago" style labels from these, which would otherwise churn every release. */ import type { Attachment, Context, Settings, StashItem } from '../src/lib/types'; +import { Temporal, toCanonical } from '../src/lib/utils/time'; /** The instant the fixtures pretend it is. */ -export const NOW = new Date('2026-01-15T14:30:00.000Z'); +export const NOW = Temporal.Instant.from('2026-01-15T14:30:00.000Z'); /** An ISO timestamp `minutes` before `NOW`. */ function ago(minutes: number): string { - return new Date(NOW.getTime() - minutes * 60_000).toISOString(); + return toCanonical(NOW.subtract({ minutes })); } /** @@ -209,7 +210,7 @@ export const settings: Settings = { email: 'dev@example.com', subscriptionTier: 'pro', subscriptionStatus: 'active', - subscriptionPeriodEnd: new Date(NOW.getTime() + 21 * 86_400_000).toISOString(), + subscriptionPeriodEnd: toCanonical(NOW.add({ hours: 21 * 24 })), lastSyncAt: ago(2), }, }; diff --git a/screenshots/mock-backend.ts b/screenshots/mock-backend.ts index 2e3707d..a3fd298 100644 --- a/screenshots/mock-backend.ts +++ b/screenshots/mock-backend.ts @@ -29,6 +29,7 @@ import type { InvokeArgs } from '@tauri-apps/api/core'; import { mockIPC, mockWindows } from '@tauri-apps/api/mocks'; import type { Attachment, Context, Settings, StashItem } from '../src/lib/types'; import * as fixtures from './fixtures'; +import { toCanonical } from '../src/lib/utils/time'; /** Commands whose answer is "nothing happened, carry on". */ const NO_OP = new Set([ @@ -172,7 +173,7 @@ export function installMockBackend(overrides: Partial = {}): void { case 'sync_stashes_api': return { synced: [], - serverTime: fixtures.NOW.toISOString(), + serverTime: toCanonical(fixtures.NOW), rejected: [], partial: true, positions: [], @@ -180,7 +181,7 @@ export function installMockBackend(overrides: Partial = {}): void { case 'sync_contexts_api': return { synced: [], - serverTime: fixtures.NOW.toISOString(), + serverTime: toCanonical(fixtures.NOW), rejected: [], }; case 'upload_attachment_to_cloud': @@ -263,7 +264,7 @@ export function installMockBackend(overrides: Partial = {}): void { fileName, fileSize: 2_048, mimeType: 'text/plain', - createdAt: fixtures.NOW.toISOString(), + createdAt: toCanonical(fixtures.NOW), }; } } diff --git a/scripts/check-temporal.mjs b/scripts/check-temporal.mjs new file mode 100644 index 0000000..6f58a3e --- /dev/null +++ b/scripts/check-temporal.mjs @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +// Fails when anything in the tracked source reaches for an old date API instead of +// Temporal. +// +// Every date and time in the frontend goes through `src/lib/utils/time.ts`, which takes +// Temporal from the polyfill as a value, and every one in the backend through +// `src-tauri/src/time.rs`, on `temporal_rs`. Three things break that, and none of them +// shows up as an error: +// +// * A `Date` slips back in. It parses zoneless strings as local time in some engines and +// rejects them in others, and its `toISOString()` writes a different width than the +// backend, so two timestamps that look comparable as strings no longer are. +// * Something imports the polyfill on its own, or uses a global `Temporal`. The app would +// then run native Temporal on Windows and the polyfill on macOS and Linux. +// * `chrono` or `SystemTime` comes back in the backend, with its own idea of the format. +// +// Run with `npm run check:temporal`. CI runs it with the frontend tests. +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const TIME_MODULE = 'src/lib/utils/time.ts'; +const RUST_TIME_MODULE = 'src-tauri/src/time.rs'; + +const RULES = [ + { + what: '`Date` - use the helpers in src/lib/utils/time.ts', + files: /\.(ts|js|mjs|svelte)$/, + pattern: /\bnew Date\b|\bDate\.[A-Za-z]|\binstanceof Date\b|[:<|,(]\s*Date\b(?!\s*:)|\bglobalThis\.Date\b/, + }, + { + what: 'the Temporal polyfill imported outside src/lib/utils/time.ts', + files: /\.(ts|js|mjs|svelte)$/, + pattern: /["'](temporal-polyfill|@js-temporal\/polyfill)(\/[^"']*)?["']/, + allow: TIME_MODULE, + }, + { + what: 'a global Temporal - import it from src/lib/utils/time.ts', + files: /\.(ts|js|mjs|svelte)$/, + pattern: /\bglobalThis\.Temporal\b/, + }, + { + what: '`chrono` - use src-tauri/src/time.rs', + files: /\.rs$|Cargo\.toml$/, + pattern: /\bchrono\b/, + }, + { + what: '`SystemTime` - use src-tauri/src/time.rs', + files: /\.rs$/, + pattern: /\bSystemTime\b|\bUNIX_EPOCH\b/, + allow: RUST_TIME_MODULE, + }, +]; + +/** Comment lines, which may name what was replaced. */ +const COMMENT = /^\s*(\/\/|\*|\/\*|