feat(ids)!: normalize persistent identifiers to Base62

BREAKING CHANGE: historical entity IDs, public links, sessions, API keys, OAuth credentials, and related references require the documented one-time backfill before this runtime is deployed.
This commit is contained in:
saltbo
2026-08-04 17:43:23 -04:00
parent 50a9895fcf
commit 0f3707e0bf
127 changed files with 22926 additions and 577 deletions
+112
View File
@@ -0,0 +1,112 @@
# Identifier normalization
## Release boundary
ZPan-owned persistent entity identifiers and public opaque tokens use the fixed alphabet
`0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz` and satisfy
`^[A-Za-z0-9]+$`. This is a breaking, maintenance-window migration: historical values are
backfilled before the new application starts. Runtime code contains no legacy lookup,
prefix parser, or old-to-new alias table.
The central generator is `shared/ids.ts`. A repository lint rejects imports of Nano ID's
default `nanoid()` generator from production and operational source and permits reviewed
`customAlphabet()` use only for the central generator, organization slugs, and usernames.
Set-based SQLite inserts use `randomblob()` encoded as Base16 (or deterministic hex of a
canonical JSON tuple for rebuildable stats rows); these values are cryptographically random
or injective, ASCII alphanumeric, and cannot call a TypeScript generator inside SQL. Better Auth remains on its upstream
database-ID generator because Better Auth 1.7 already uses `a-zA-Z0-9`; replacing it would
couple ZPan to authentication internals without changing the contract.
## Entropy budget
Base62 provides `log2(62) = 5.954` bits per character. Lengths were rounded up when replacing
Nano ID's 64-character alphabet, so entropy never decreases.
| Use | Old entropy | New format | New entropy |
| --- | ---: | --- | ---: |
| Default entity ID | Nano ID 21, 126 bits | Base62 22 | 131.0 bits |
| Share / matter alias | Nano ID 10, 60 bits | Base62 11 | 65.5 bits |
| Image ID | Nano ID 12, 72 bits | Base62 13 | 77.4 bits |
| Image public token | prefixed/default Nano ID, at most 60 bits | Base62 12 | 71.4 bits |
| Short filename suffix | Nano ID 4, 24 bits | Base62 5 | 29.8 bits |
| Invite code | upper alphanumeric 8, 41.4 bits | Base62 8 | 47.6 bits |
| OAuth PAR suffix | Nano ID 32, 192 bits | Base62 33 | 196.5 bits |
| Registration management token | 32 random bytes, 256 bits | Base62 43 | 256.0 bits |
The collision budget uses `p ≈ n(n-1)/(2 × 62^length)`:
| Namespace | Population assumption | Approximate collision probability | Guard |
| --- | ---: | ---: | --- |
| 8-char invite codes | 100,000 simultaneously retained codes | `2.3e-5` | unique index; conflict fails the create |
| 11-char share/alias tokens | 10,000,000 retained tokens | `9.6e-7` | unique index; `/r` tokens reserve a shared registry row in the resource transaction and retry bounded collisions |
| 12-char image tokens | 10,000,000 retained tokens | `1.5e-8` | unique index; same transactional `/r` registry and bounded retry |
| 22-char entity IDs | 1,000,000,000 retained IDs | `1.8e-22` | primary/unique key |
The shortest token is a one-time invite code and its active population is expected to stay
well below the stated 100,000-code budget. A collision never aliases an existing resource:
the database rejects it. Entity IDs have a materially larger margin.
## Repository-wide inventory
The baseline audit found 57 direct default `nanoid()` calls in 31 production files. The
following table classifies generators and persisted or embedded values by ownership. “Migrate”
means the one-time backfill rewrites historical values. “Future only” means the value is not
an opaque entity ID, but its random component now uses the central generator.
| Class | Values and locations | Action |
| --- | --- | --- |
| ZPan entity primary IDs | matters, storages, quotas/entitlements, invites, notifications, jobs, downloaders/tasks, upload sessions, usage reports, announcements, audit/stat/ledger rows, shares/recipients, image hosting, WebDAV state, license binding, webhook/x402/cloud report rows | Migrate PK plus references; all creation paths use `generateId()` |
| Better Auth entity IDs | user, account, organization, member, invitation, API key, OAuth client/resource linking rows | Verify/migrate any historical exception; retain upstream Base62 generator and contract tests |
| Public opaque tokens | matter alias, invite code, site/team invite, share, image-hosting, image-domain verification, downloader JTI, instance ID | Migrate; all new values are Base62 |
| Direct-share `ds_` token | share token and `/r/:token` | Replace with 11-char Base62; reserve the shared redirect registry in the share transaction, then resolve database ownership and fail closed on any integrity mismatch |
| Image public token | image-hosting public token | Replace with 12-char Base62 without a semantic prefix; reserve the same registry namespace transactionally and derive resource type from the database |
| Event/source/idempotency keys | `traffic_<id>`, `admin_grant:<id>`, `mutation:<id>`, ledger event keys, webhook event IDs | Future random component uses Base62 where ZPan generates it; separators remain because these are structured business keys, not entity IDs |
| Object/cache keys | R2/S3 `object`, `storage_key`, image paths, cache keys, file paths | Do not rename. The database stores the authoritative key, so remapping a row ID does not move the object |
| JSON/polymorphic references | notification/job/audit/resource-change/stat/API-key metadata; `ref_id`, `target_id`, `scope_id`, `resource_id`, actor refs | Rewrite exact mapped values; malformed JSON fails the plan before mutation |
| Cloud / Store / ZPan Cloud identifiers | binding/store/account IDs, cloud event IDs and raw webhook payloads | External system owns them; never rewrite. If the instance ID changes, disconnect the local binding and reconcile/rebind explicitly |
| Better Auth credentials | sessions, verification, JWKS, device codes, OAuth grants/access/refresh tokens, consent, PAR, JWT revocations, registration management credentials, and provider access/refresh/ID tokens stored on accounts | Invalidate or clear during maintenance; require login/re-authorization/key rotation instead of synthesizing compatibility; password hashes and provider linkage remain |
| Downloader credential claims | PASETO subject/JTI and bootstrap credentials | Disable downloader and delete bootstrap credentials; re-register to issue claims over the new ID |
| HTTP request ID | `crypto.randomUUID()` | Protocol/observability identifier; preserve UUID |
| WebDAV lock token | `opaquelocktoken:${crypto.randomUUID()}` | WebDAV URI token; preserve standard format |
| Object challenge ID | UUID inside a signed upload challenge | Signed protocol field; preserve until challenge expiry |
| OAuth request URI | `urn:ietf:params:oauth:request_uri:<Base62>` | Preserve registered URN prefix; only the opaque suffix changes |
| Signed page cursor / share child ref | ZPan-owned transient public opaque references | Encode a versioned signed binary envelope as Base62; legacy Base64url/dotted forms expire at the release boundary with no dual decoder |
| OAuth state, PKCE, JWT claims | provider-owned Base64url, URIs and signed claims | Protocol-owned; do not apply the entity-ID regex |
| Cryptographic salt/hash/random bytes | password salts, secret hashes, signatures | Cryptographic encoding boundary; preserve |
| UI-only IDs | transient React list/chip keys using UUID | Ephemeral and not persisted; preserve |
## Backfill invariants
The planner builds random old-to-new mappings; it never strips or substitutes punctuation.
One invalid entity value maps to one stable replacement across PK/FK and embedded references.
Table-scoped JSON handling rewrites scalar and array ID keys used by notifications, archive
jobs, audit and quota attribution, plus notification share tokens from the share-token
namespace; arbitrary strings and external IDs are left untouched.
Token mappings remain table-scoped, while candidate generation and the durable
`redirect_token_registry` enforce one shared namespace for direct-share and image `/r` tokens.
Mappings live only in `_zpan_id_backfill_map`, an operational checkpoint table that runtime
code never reads. Finalization drops it after verification.
Apply runs with foreign keys enabled and deferred inside one SQLite transaction. It checks
database integrity, foreign keys, zero illegal values, redirect ambiguity, and unchanged row
counts for every table except explicitly invalidated credential tables. The generated D1 plan
keeps each statement under D1's 100 KB statement limit and is re-entrant through `INSERT OR
IGNORE`. For a production D1 database, the application must be quiesced and the exact
generated statement array must succeed in one rehearsed `D1Database.batch()` transaction;
Wrangler SQL-file execution and ad hoc multi-batch splitting are not supported. No online
mixed-format phase exists.
The planner coalesces reference assignments by table and JSON rewrites into size-bounded CASE
updates. It refuses artifacts above 47 statements, leaving the executor's three preflight and
checkpoint queries within D1 Free's 50-query invocation cap. Empty, representative cross-table,
and 1,000-document JSON fixtures assert this ceiling; an oversized fixture proves fail-fast
without mutation. Snapshots above the ceiling use the runbook's offline normalized-new-D1 import
and application binding switch, with the untouched old D1 as the pre-write rollback point. They
are never divided into in-place batches. After an exact artifact is applied, its
digest is the cheap startup checkpoint; the full unindexed scans run in the maintenance
finalizer rather than on every new Worker isolate.
Every public share/image/invite token is rotated, including already-alphanumeric values, so
old links intentionally stop working. The optional
0600 mapping export supports owner notification or a controlled link-export process, but must
never be deployed as a runtime lookup table.
+8 -8
View File
@@ -26,7 +26,7 @@ image_hosting_configs
image_hostings
id PK // opaque internal id, also drives storageKey
orgId
token UNIQUE // "ih_" + nanoid(10); supports token URL access
token UNIQUE // Base62 public token; supports token URL access
path // user-visible virtual path, e.g. "blog/2026/04/screenshot.png"
UNIQUE (orgId, path) // path must be unique within an org
storageKey // "ih/<orgId>/<id>" — flat, ID-keyed, independent of path
@@ -59,7 +59,7 @@ Two URL forms coexist, selected by domain:
| Domain | Accepted URL shape | Example |
|---|---|---|
| **Default app domain** (`zpan.io`) | Token URL only: `/r/:token` | `https://zpan.io/r/ih_aB3xK9.png` |
| **Default app domain** (`zpan.io`) | Token URL only: `/r/:token` | `https://zpan.io/r/Ab3xK9Lm2PqR.png` |
| **Custom domain** (`img.user.com`) | Path URL only: `/<virtualPath>` | `https://img.user.com/blog/2026/04/screenshot.png` |
Rationale for the strict split:
@@ -70,14 +70,14 @@ Rationale for the strict split:
### Route: `/r/:token`
Shared with direct shares (old `/d/:token` is removed outright; no users in production, no alias kept). Token prefix disambiguates:
Shared with direct shares (old `/d/:token` is removed outright; no users in production, no alias kept). The database record disambiguates resource type:
| Prefix | Kind | Cache-Control | Content-Disposition |
| Database record | Kind | Cache-Control | Content-Disposition |
|---|---|---|---|
| `ds_` | Direct share (existing) | `no-store` | `attachment` |
| `ih_` | Image hosting | `public, max-age=300` | `inline` |
| active `shares` row with `kind=direct` | Direct share | `no-store` | `attachment` |
| active `image_hostings` row | Image hosting | `public, max-age=300` | `inline` |
Optional file extension for Markdown / browser hinting: `/r/ih_aB3xK9.png`. Server ignores the extension when resolving — only the token is authoritative. Extension is derived from stored `mime` at upload.
Optional file extension for Markdown / browser hinting: `/r/Ab3xK9Lm2PqR.png`. Server ignores the extension when resolving — only the token is authoritative. Extension is derived from stored `mime` at upload.
`max-age=300` is deliberately shorter than `PRESIGN_TTL_SECS` so cached 302s never point to expired presigned URLs.
@@ -126,7 +126,7 @@ app.route('/api', apiRouter)
{
"data": {
"url": "https://img.myblog.com/blog/2026/04/screenshot.png",
"urlAlt": "https://zpan.io/r/ih_aB3xK9.png",
"urlAlt": "https://zpan.io/r/Ab3xK9Lm2PqR.png",
"markdown": "![](https://img.myblog.com/blog/2026/04/screenshot.png)",
"html": "<img src=\"https://img.myblog.com/blog/2026/04/screenshot.png\" />",
"bbcode": "[img]https://img.myblog.com/blog/2026/04/screenshot.png[/img]"
+190
View File
@@ -0,0 +1,190 @@
# ID normalization release runbook
This runbook is destructive. It requires a separate, explicit production approval. The
implementation and tests do not run `wrangler d1 execute --remote` or mutate production.
## Prepare and dry-run
1. Record the application commit and current D1 migration version. Announce a maintenance
window that covers database rewrite, credential invalidation and post-release checks.
2. Export a recoverable production backup with `wrangler d1 export <database> --remote
--output pre-id-normalization.sql`. Store it according to the incident-recovery policy and
test importing it into a new local D1 database.
3. Import the export into an isolated SQLite/D1 rehearsal database. Apply migration
`0092_base62_audit_event_key.sql`, `0093_redirect-token-registry.sql`, and
`0094_redirect-token-kind-resource.sql` to the rehearsal
copy first; the old application can tolerate the nullable audit column and unused registry,
while the planner intentionally refuses a database without them. Then run:
```sh
pnpm ids:backfill -- --sqlite rehearsal.sqlite \
--plan-file id-normalization.sql \
--batch-file id-normalization-batch.json \
--mapping-file id-normalization-map.json
```
All output files are sensitive plaintext, created with mode 0600, and refuse to overwrite
an existing file. Encrypt them at rest under the incident-recovery policy and delete them
after the observation window. The console prints counts only, never old/new values.
4. Review counts, malformed-JSON failures, external binding/downloader impact, and the secure
share/image/invite mapping export with the operations owner. Resolve any direct-share/image
token collision before proceeding.
## Maintenance-window apply
1. Stop writes and background consumers. Confirm no upload, WebDAV, download, quota, webhook,
or OAuth request can write during the rewrite. Take a second export and record row counts.
2. Apply migrations `0092_base62_audit_event_key.sql`, `0093_redirect-token-registry.sql`, and
`0094_redirect-token-kind-resource.sql`
while the old application remains quiesced, export again, and regenerate the plan from that
exact post-migration snapshot.
Do not reuse a plan from an earlier snapshot.
3. On a local SQLite deployment, apply with the explicit credential acknowledgement:
```sh
pnpm ids:backfill -- --sqlite zpan.sqlite --apply \
--confirm-credential-invalidation \
--mapping-file id-normalization-map.json
```
The same transaction writes `id_normalization_pending_artifact_digest` from the exact plan.
The breaking application accepts that verified observation-window checkpoint. Local rollback
removes it; local finalize moves the exact value to `id_normalization_applied_artifact_digest`
while writing `id_normalization_version=1`.
4. On D1, do **not** pass the SQL file to `wrangler d1 execute`: file execution is not the
atomicity boundary this migration requires. The approved maintenance executor must load
the exact generated JSON statement array and submit all statements in one `env.DB.batch()`.
D1 keeps foreign keys enabled; the first statement is `PRAGMA defer_foreign_keys = ON`.
Copy `wrangler.id-backfill.example.toml` outside the repository, replace both D1 placeholders,
and have a second operator verify that they identify the clone. The dedicated template has no
application routes, assets, queues, cron triggers, or service bindings. Deploy it with
`pnpm wrangler deploy --config /secure/path/wrangler.id-backfill.toml`, then set its secret with
`pnpm wrangler secret put ID_BACKFILL_AUTH_TOKEN --config /secure/path/wrangler.id-backfill.toml`.
Never use the application's default `wrangler.toml` for this executor. Then POST the generated artifact
with `Authorization: Bearer …`, `X-ZPan-ID-Backfill-Confirm:
invalidate-credentials-and-links`, and `X-ZPan-ID-Backfill-Digest: <artifact.digest>`.
The executor verifies the SHA-256 digest, completion marker, a 47-statement artifact ceiling,
and statement-size/first-statement contract before issuing exactly one `env.DB.batch()`.
That same batch records the pending
artifact digest; a retry of the identical artifact is a no-op and a different artifact is
rejected. The ceiling leaves three queries for preflight and the pending-digest insert under
D1 Free's 50-query invocation cap; JSON rewrites are coalesced into size-bounded CASE updates.
The response contains only the digest and statement count. Delete
the Worker after the rehearsal. Repeat the same executor and exact artifact in the approved
production window; never expose it through the application Worker.
Rehearse that exact artifact through the one-shot Worker against an isolated D1 clone and
prove both successful commit and injected-failure rollback. The checked-in CF test proves
the executor's digest gate plus representative PK/FK, structured key, JSON, public-token,
credential invalidation and rollback behavior; the clone rehearsal proves the exact
database-sized artifact. If the exact artifact exceeds request, batch, CPU, statement,
or database limits, **do not apply it to the serving database**. The planner fails before
writing when it cannot fit the exact snapshot into the safe one-batch ceiling. Use the
offline new-database procedure below instead; never split an in-place rewrite into batches.
5. Run the planner again in dry-run mode. It must report zero invalid IDs/tokens, zero
ambiguous redirects, zero credentials awaiting invalidation, and zero JSON rewrites.
Run `PRAGMA foreign_key_check`, compare every pre/post table row count except documented
credential tables, check all unique indexes, and reconcile quota/audit/job invariants.
6. Deploy the breaking application build. Re-register downloaders, rotate JWKS through normal
authentication startup, require users to sign in again, and require OAuth clients and
social-login accounts to authorize again. Provider access/refresh/ID tokens are cleared,
while credential password hashes and provider account linkage remain. Rebind ZPan Cloud if
the instance ID changed; its local binding is deliberately marked disconnected.
7. Notify link owners that old share, image and invite URLs are invalid. Use the encrypted-at-
rest mapping export only for notification/export; do not serve redirects from it. Every
public token is rotated, including values that were already alphanumeric.
D1 limits and behavior should be rechecked immediately before the production window:
[limits](https://developers.cloudflare.com/d1/platform/limits/),
[foreign keys](https://developers.cloudflare.com/d1/sql-api/foreign-keys/),
[SQL statements](https://developers.cloudflare.com/d1/sql-api/sql-statements/), and
[import/export](https://developers.cloudflare.com/d1/best-practices/import-export-data/).
## D1 snapshots above the atomic query ceiling
An exact artifact above 47 statements is a supported release shape, but only through an
offline blue-green database replacement. This path does not mutate the old D1 database and
therefore does not need runtime old/new compatibility or an unsafe partially normalized state.
Every remote command and the final binding switch still require separate production approval.
1. Keep the application and every consumer quiesced from the final export until the binding
switch is accepted. Record a D1 Time Travel bookmark and export the exact post-0094 database.
The old D1 database is the immutable rollback point; do not apply the generated D1 artifact
to it.
2. Import that export into local SQLite, run `pnpm ids:backfill -- --sqlite ... --apply
--confirm-credential-invalidation`, perform all pre/post invariants, and run `--finalize`.
This uses one local SQLite transaction without D1's per-invocation query ceiling. Repeat from
the untouched export after an injected interruption and prove that only the complete result
survives. The finalized database must contain `id_normalization_version=1`, no pending digest
and no mapping table.
3. Convert the normalized SQLite database to D1-compatible SQL. Follow Cloudflare's documented
SQLite `.dump` procedure exactly: remove the outer `BEGIN TRANSACTION`/`COMMIT` and the
`_cf_KV` statements. Preserve the source export, normalized SQLite file, final SQL, SHA-256
digests, table row counts and verification report as separate encrypted release artifacts.
4. Create a **new, empty, unbound** D1 database and import the normalized SQL with Wrangler.
Cloudflare currently accepts import files up to 5 GiB; split larger imports according to the
current official procedure. Because this target serves no traffic, a failed or interrupted
import is discarded and recreated instead of resumed in place. For a split import, record
the expected and actual row count after every file, and do not proceed to the next file on a
mismatch. Do not bind the target to an application until the final global checks pass.
5. Re-export the new D1 database and compare every table row count with the normalized local
source. Run `foreign_key_check`, zero-invalid ID/token scans, redirect-registry consistency,
uniqueness checks and business invariants against the re-export. Rehearse the new application
against this isolated target using a maintenance-only configuration.
6. In one reviewed deployment, switch the application D1 binding to the new database and deploy
the breaking application. Keep writes stopped while read-only routing, authentication
invalidation and representative reads are checked. If any check fails, restore the previous
application deployment and old database binding; the old D1 is unchanged. Open writes only
after explicit release acceptance. Once writes open, reverting to the old database would lose
accepted writes and requires a separately approved reconciliation; prefer a forward fix.
7. Retain the old D1, exports and digests through the observation window. Delete the replacement
attempt on any pre-switch failure. Do not run old and new applications concurrently, replicate
writes, or add legacy token lookup during this process.
Cloudflare D1 export blocks other requests while it runs, import/export has format constraints,
and Time Travel restore is destructive and in-place. Reconfirm those current platform semantics,
limits and retention immediately before approval; do not treat this runbook as authority to run
the remote commands.
## Rollback and reconciliation
Do not finalize until the release owner accepts post-deploy verification.
- Before credential invalidation or any uncertain D1 partial execution, the authoritative
rollback is to restore the quiesced pre-change export and redeploy the previous application.
- While `_zpan_id_backfill_map` remains, `pnpm ids:backfill -- --sqlite zpan.sqlite --rollback`
reverses mapped IDs/tokens and embedded references for local recovery. Deliberately deleted
credentials are not reconstructible; restore the backup if they are needed.
- For D1, generate/review reversal from the applied snapshot or restore the backup into a new
database and switch bindings according to the D1 recovery procedure. Never run old and new
applications concurrently against a partially reversed database.
- Reconcile S3/R2 objects by stored `object`/`storage_key`; those keys are intentionally not
renamed. Reconcile Cloud binding state manually because Cloud-owned IDs are not rewritten.
After the observation window, verify again and drop the checkpoint:
```sh
pnpm ids:backfill -- --sqlite zpan.sqlite --finalize
```
For D1, keep the same temporary maintenance Worker bound to the quiesced database and POST
`{"version":1,"digest":"<artifact.digest>"}` to `/finalize` with the same bearer secret and
`X-ZPan-ID-Backfill-Confirm: finalize-id-normalization`. The executor rechecks all foreign keys,
the Base62 ID/token scans and redirect-registry consistency; requires the
mapping checkpoint and exact pending digest to exist; and atomically writes both the completion
version and applied artifact digest before dropping the mapping table. Credentials created by
the new application during the observation window are allowed; invalidation of pre-migration
credentials is proven in the apply artifact rehearsal. Its CF test
proves completion and repeat-finalize rejection. Remove the maintenance Worker immediately
after the successful response with
`pnpm wrangler delete --config /secure/path/wrangler.id-backfill.toml`.
D1 rejects `PRAGMA integrity_check` through its prepared API. Run that check on the exact
exported SQLite rehearsal snapshot before finalization; the live D1 finalizer uses the
supported foreign-key and semantic scans listed above.
Finalization removes the local mapping table and therefore removes mapping-based rollback;
the retained database export remains the disaster-recovery point. It atomically writes
`system_options.id_normalization_version=1`; later dry-run/apply/finalize attempts fail fast
instead of rotating public tokens a second time.
@@ -0,0 +1,2 @@
ALTER TABLE `audit_events` ADD `event_key` text;--> statement-breakpoint
CREATE UNIQUE INDEX `audit_events_event_key_unique` ON `audit_events` (`event_key`);
@@ -0,0 +1,7 @@
CREATE TABLE `redirect_token_registry` (
`token` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`resource_id` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `redirect_token_registry_resource_id_unique` ON `redirect_token_registry` (`resource_id`);
@@ -0,0 +1,2 @@
DROP INDEX `redirect_token_registry_resource_id_unique`;--> statement-breakpoint
CREATE UNIQUE INDEX `redirect_token_registry_kind_resource_id_unique` ON `redirect_token_registry` (`kind`,`resource_id`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -638,6 +638,27 @@
"when": 1785721689950,
"tag": "0091_oauth-client-registration-management",
"breakpoints": true
},
{
"idx": 92,
"version": "6",
"when": 1785868980173,
"tag": "0092_base62_audit_event_key",
"breakpoints": true
},
{
"idx": 93,
"version": "6",
"when": 1785875392712,
"tag": "0093_redirect-token-registry",
"breakpoints": true
},
{
"idx": 94,
"version": "6",
"when": 1785876121935,
"tag": "0094_redirect-token-kind-resource",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -30,12 +30,13 @@
"storage-status:backfill": "tsx scripts/backfill-storage-enabled-status.ts",
"api-key-scopes:backfill": "tsx scripts/backfill-api-key-scopes.ts",
"oauth-scopes:backfill": "tsx scripts/backfill-oauth-scopes.ts",
"ids:backfill": "tsx scripts/backfill-ids.ts",
"typecheck": "tsc --noEmit -p server/tsconfig.json && tsc --noEmit -p src/tsconfig.json",
"test": "vitest run --project unit --project integration",
"test:cf": "vitest run --project cloudflare",
"test:libsql": "vitest run --project libsql",
"test:watch": "vitest --project unit --project integration",
"lint": "biome check .",
"lint": "biome check . && node scripts/lint-id-generation.mjs",
"lint:fix": "biome check --write .",
"lint:arch": "depcruise server/ shared/ --config .dependency-cruiser.cjs",
"lint:http": "tsx scripts/lint-http-boundary.ts",
+16 -15
View File
@@ -19,13 +19,13 @@ import { ADMIN_STATS_METRICS as M } from '../server/domain/admin-stats-metrics'
const MIN_VALID_TIMESTAMP_MS = Date.UTC(2000, 0, 1)
const MAX_BACKFILL_HOURS = 100_000
const STATISTICS_OPENING_SOURCE_ID = 'v3-authoritative-sources'
const STATISTICS_OPENING_EVENT_ID = `audit:statistics_source_initialized:${STATISTICS_OPENING_SOURCE_ID}`
const STATISTICS_OPENING_EVENT_KEY = `audit:statistics_source_initialized:${STATISTICS_OPENING_SOURCE_ID}`
const STATISTICS_OPENING_OPTION_KEY = 'stats_integrity_exact_from_v3'
const TRAFFIC_LEDGER_OPENING_EVENT_ID = 'traffic_ledger_opening_v1'
const statisticsExactFromMsSql = `COALESCE(
(SELECT unixepoch(value) * 1000 FROM system_options WHERE key = '${STATISTICS_OPENING_OPTION_KEY}'),
(SELECT created_at * 1000 FROM audit_events WHERE id = '${STATISTICS_OPENING_EVENT_ID}'),
(SELECT created_at * 1000 FROM audit_events WHERE event_key = '${STATISTICS_OPENING_EVENT_KEY}'),
(unixepoch() + 1) * 1000
)`
const statisticsFirstFullHourMsSql = `CAST((${statisticsExactFromMsSql} + 3599999) / 3600000 AS INTEGER) * 3600000`
@@ -113,19 +113,19 @@ INSERT OR IGNORE INTO system_options (key, value)
VALUES (
'${STATISTICS_OPENING_OPTION_KEY}',
COALESCE(
(SELECT strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch') FROM audit_events WHERE id = '${STATISTICS_OPENING_EVENT_ID}'),
(SELECT strftime('%Y-%m-%dT%H:%M:%fZ', created_at, 'unixepoch') FROM audit_events WHERE event_key = '${STATISTICS_OPENING_EVENT_KEY}'),
'${openingAt}'
)
);
DELETE FROM audit_events WHERE id = '${STATISTICS_OPENING_EVENT_ID}';
DELETE FROM audit_events WHERE event_key = '${STATISTICS_OPENING_EVENT_KEY}';
INSERT OR IGNORE INTO cloud_traffic_reports (
id, org_id, period, source, source_id, event_id, bytes, storage_id,
unit_bytes, credits_per_unit, status, error, attempt_count, next_retry_at,
issued_at, created_at, updated_at
) VALUES (
'traffic_ledger_opening_v1', '', '${trafficPeriod}', 'object_download',
lower(hex('traffic_ledger_opening_v1')), '', '${trafficPeriod}', 'object_download',
'traffic_ledger_opening_v1', 'traffic_ledger_opening_v1', 0, NULL,
NULL, NULL, 'ledger_opening', NULL, 0, NULL, NULL, ${now.getTime()}, ${now.getTime()}
);
@@ -135,10 +135,11 @@ SET actor_type = CASE WHEN user_id IS NULL THEN 'anonymous' ELSE 'user' END
WHERE actor_type IS NULL;
INSERT OR IGNORE INTO audit_events (
id, org_id, user_id, actor_type, actor_ref, action, target_type,
id, event_key, org_id, user_id, actor_type, actor_ref, action, target_type,
target_id, target_name, metadata, created_at
)
SELECT
lower(hex('event:user_register:' || registered_user.id)),
'event:user_register:' || registered_user.id,
'',
registered_user.id,
@@ -324,7 +325,7 @@ function userSignupHistoryStartSql(currentHour: number): string {
WHERE action = 'user_register'
AND target_id IS NOT NULL
AND user_id = target_id
AND id = 'event:user_register:' || target_id
AND event_key = 'event:user_register:' || target_id
AND json_valid(metadata) = 1
AND json_type(metadata, '$.provider') = 'text'
AND length(json_extract(metadata, '$.provider')) > 0
@@ -477,7 +478,7 @@ INSERT INTO stats_rollups_hourly (
count, bytes, unique_count, metadata, updated_at
)
SELECT
CAST(bucket_start AS TEXT) || ':global:stats.rollup_run:metric_key:${metric}',
lower(hex(json_array(bucket_start, '', 'stats.rollup_run', 'metric_key', '${metric}'))),
bucket_start, '', 'stats.rollup_run', 'metric_key', '${metric}', 1, 0, 0,
json_object('version', 3, 'scope', 'counters', 'quality', 'exact'),
bucket_start + 3600000
@@ -529,7 +530,7 @@ INSERT INTO stats_rollups_hourly (
count, bytes, unique_count, metadata, updated_at
)
SELECT
CAST(buckets.bucket_start AS TEXT) || ':global:stats.rollup_run:all:all',
lower(hex(json_array(buckets.bucket_start, '', 'stats.rollup_run', '', ''))),
buckets.bucket_start, '', 'stats.rollup_run', '', '', 1, 0, 0,
CASE WHEN snapshot_markers.bucket_start IS NULL THEN
json_object(
@@ -686,7 +687,7 @@ export function buildValidationSql(now = new Date()): string {
WHERE action = 'user_register'
AND target_id IS NOT NULL
AND user_id = target_id
AND id = 'event:user_register:' || target_id
AND event_key = 'event:user_register:' || target_id
AND json_valid(metadata) = 1
AND json_type(metadata, '$.provider') = 'text'
AND length(json_extract(metadata, '$.provider')) > 0
@@ -777,7 +778,7 @@ export function buildValidationSql(now = new Date()): string {
json_valid(metadata) = 0
OR target_id IS NULL
OR user_id <> target_id
OR id <> 'event:user_register:' || target_id
OR event_key <> 'event:user_register:' || target_id
OR COALESCE(json_type(metadata, '$.provider') = 'text', 0) = 0
OR COALESCE(length(json_extract(metadata, '$.provider')), 0) = 0
))
@@ -807,7 +808,7 @@ export function buildValidationSql(now = new Date()): string {
SELECT 1
FROM audit_events registration_event
WHERE registration_event.action = 'user_register'
AND registration_event.id = 'event:user_register:' || registered_user.id
AND registration_event.event_key = 'event:user_register:' || registered_user.id
AND registration_event.user_id = registered_user.id
AND registration_event.target_id = registered_user.id
AND registration_event.created_at = CAST(registered_user.created_at / 1000 AS INTEGER)
@@ -932,7 +933,7 @@ authoritative_registration_sources AS MATERIALIZED (
WHERE action = 'user_register'
AND target_id IS NOT NULL
AND user_id = target_id
AND id = 'event:user_register:' || target_id
AND event_key = 'event:user_register:' || target_id
AND json_valid(metadata) = 1
AND json_type(metadata, '$.provider') = 'text'
AND length(json_extract(metadata, '$.provider')) > 0
@@ -1225,7 +1226,7 @@ SELECT json_object(
AND (
ae.target_id IS NULL
OR ae.user_id <> ae.target_id
OR ae.id <> 'event:user_register:' || ae.target_id
OR ae.event_key <> 'event:user_register:' || ae.target_id
OR
COALESCE(json_type(ae.metadata, '$.provider') = 'text', 0) = 0
OR COALESCE(length(json_extract(ae.metadata, '$.provider')), 0) = 0
@@ -1238,7 +1239,7 @@ SELECT json_object(
WHERE NOT EXISTS (
SELECT 1
FROM audit_events registration_event
WHERE registration_event.id = 'event:user_register:' || registered_user.id
WHERE registration_event.event_key = 'event:user_register:' || registered_user.id
)
),
'issuedTrafficReportsToRecover', (
+92
View File
@@ -0,0 +1,92 @@
import { writeFileSync } from 'node:fs'
import Database from 'better-sqlite3'
import {
applyBackfill,
backfillPlanDigest,
createBackfillPlan,
finalizeBackfill,
inspectBackfill,
pendingBackfillDigest,
rollbackBackfill,
} from './id-backfill-core'
interface Options {
database: string
mode: 'dry-run' | 'apply' | 'rollback' | 'finalize'
planFile?: string
batchFile?: string
mappingFile?: string
confirmInvalidation: boolean
}
function valueAfter(argv: string[], flag: string): string | undefined {
const index = argv.indexOf(flag)
return index >= 0 ? argv[index + 1] : undefined
}
function parseOptions(argv: string[]): Options {
const database = valueAfter(argv, '--sqlite')
if (!database) usage()
const modes = ['--apply', '--rollback', '--finalize'].filter((flag) => argv.includes(flag))
if (modes.length > 1) usage()
return {
database,
mode: modes[0]?.slice(2) as Options['mode'] | undefined ?? 'dry-run',
planFile: valueAfter(argv, '--plan-file'),
batchFile: valueAfter(argv, '--batch-file'),
mappingFile: valueAfter(argv, '--mapping-file'),
confirmInvalidation: argv.includes('--confirm-credential-invalidation'),
}
}
function usage(): never {
throw new Error(
'Usage: pnpm ids:backfill -- --sqlite <export.sqlite> [--apply|--rollback|--finalize] [--confirm-credential-invalidation] [--plan-file <sql>] [--batch-file <json>] [--mapping-file <json>]',
)
}
function writePrivate(path: string, contents: string): void {
writeFileSync(path, contents, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
}
function main(): void {
const options = parseOptions(process.argv.slice(2))
const db = new Database(options.database, options.mode === 'dry-run' ? { readonly: true } : undefined)
try {
db.pragma('foreign_keys = ON')
if (options.mode === 'rollback') {
const after = rollbackBackfill(db)
console.log(JSON.stringify({ mode: 'rollback-complete', after }, null, 2))
return
}
if (options.mode === 'finalize') {
finalizeBackfill(db)
console.log(JSON.stringify({ mode: 'finalize-complete' }, null, 2))
return
}
if (options.mode === 'apply' && pendingBackfillDigest(db)) {
const after = applyBackfill(db)
console.log(JSON.stringify({ mode: 'apply-already-complete', after }, null, 2))
return
}
const plan = createBackfillPlan(db)
console.log(JSON.stringify({ mode: options.mode, before: plan.before, statements: plan.sql.length }, null, 2))
if (options.planFile) writePrivate(options.planFile, `${plan.sql.join('\n')}\n`)
if (options.batchFile) {
const digest = backfillPlanDigest(plan)
writePrivate(options.batchFile, `${JSON.stringify({ version: 1, digest, statements: plan.sql }, null, 2)}\n`)
}
if (options.mappingFile) writePrivate(options.mappingFile, `${JSON.stringify(plan.mappings, null, 2)}\n`)
if (options.mode === 'dry-run') return
if (plan.before.credentialsToInvalidate > 0 && !options.confirmInvalidation) {
throw new Error('credential_invalidation_confirmation_required')
}
const after = applyBackfill(db, plan)
console.log(JSON.stringify({ mode: 'apply-complete', before: plan.before, after }, null, 2))
} finally {
db.close()
}
}
main()
+413
View File
@@ -0,0 +1,413 @@
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import { describe, expect, it } from 'vitest'
import { ID_NORMALIZATION_DATA_TABLES } from '../shared/id-normalization-inventory'
import { assertIdIntegrity } from '../server/db/id-integrity'
import { applyIdBackfillArtifact, type IdBackfillBatchArtifact } from '../workers/id-backfill'
import {
applyBackfill,
createBackfillPlan,
finalizeBackfill,
inspectBackfill,
idBackfillDataTables,
rollbackBackfill,
verifyBackfill,
} from './id-backfill-core'
function sqliteD1(db: Database.Database): D1Database {
const statement = (query: string, values: unknown[] = []) => ({
bind: (...next: unknown[]) => statement(query, next),
first: async <T>() => db.prepare(query).get(...values) as T | null,
all: async <T>() => ({ results: db.prepare(query).all(...values) as T[] }),
run: async () => db.prepare(query).run(...values),
raw: async () => [],
})
return {
prepare: (query: string) => statement(query),
batch: async (statements: Array<{ run(): Promise<unknown> }>) =>
db.transaction(() => statements.map((prepared) => prepared.run()))(),
} as unknown as D1Database
}
async function artifact(statements: string[]): Promise<IdBackfillBatchArtifact> {
const bytes = new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(statements))),
)
return {
version: 1,
digest: Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''),
statements,
}
}
function fixture(): Database.Database {
const db = new Database(':memory:')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE organization (id TEXT PRIMARY KEY);
CREATE TABLE user (id TEXT PRIMARY KEY);
CREATE TABLE account (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES user(id),
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT
);
CREATE TABLE storages (id TEXT PRIMARY KEY);
CREATE TABLE matters (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organization(id),
alias TEXT NOT NULL UNIQUE,
storage_id TEXT NOT NULL REFERENCES storages(id),
object TEXT NOT NULL
);
CREATE TABLE shares (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
status TEXT NOT NULL,
matter_id TEXT NOT NULL REFERENCES matters(id),
org_id TEXT NOT NULL REFERENCES organization(id),
creator_id TEXT NOT NULL REFERENCES user(id)
);
CREATE TABLE image_hostings (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
org_id TEXT NOT NULL REFERENCES organization(id),
storage_id TEXT NOT NULL REFERENCES storages(id),
storage_key TEXT NOT NULL
);
CREATE TABLE redirect_token_registry (
token TEXT PRIMARY KEY,
kind TEXT NOT NULL,
resource_id TEXT NOT NULL
);
CREATE UNIQUE INDEX redirect_token_registry_kind_resource_id_unique
ON redirect_token_registry(kind, resource_id);
CREATE TABLE image_hosting_configs (
org_id TEXT PRIMARY KEY REFERENCES organization(id),
verification_token TEXT
);
CREATE TABLE team_invite_links (id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE);
CREATE TABLE notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, ref_id TEXT, metadata TEXT);
CREATE TABLE background_jobs (id TEXT PRIMARY KEY, org_id TEXT NOT NULL, user_id TEXT NOT NULL, metadata TEXT, result_metadata TEXT);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY, event_key TEXT UNIQUE, org_id TEXT NOT NULL, user_id TEXT,
actor_type TEXT, actor_ref TEXT, target_type TEXT NOT NULL, target_id TEXT, metadata TEXT
);
CREATE TABLE resource_changes (sequence INTEGER PRIMARY KEY AUTOINCREMENT, scope_id TEXT, resource_id TEXT, metadata TEXT);
CREATE TABLE oauthResource (id TEXT PRIMARY KEY, identifier TEXT NOT NULL UNIQUE, signing_key_id TEXT);
CREATE TABLE oauthClientResource (id TEXT PRIMARY KEY, client_id TEXT NOT NULL, resource_id TEXT NOT NULL);
CREATE TABLE x402_capacity_purchase_intents (id TEXT PRIMARY KEY, org_id TEXT NOT NULL, resource_id TEXT NOT NULL);
CREATE TABLE org_quota_entitlements (id TEXT PRIMARY KEY, org_id TEXT NOT NULL, metadata TEXT);
CREATE TABLE cloud_traffic_reports (
id TEXT PRIMARY KEY, org_id TEXT NOT NULL, storage_id TEXT, source TEXT NOT NULL, source_id TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE
);
CREATE TABLE storage_usage_ledger (
id TEXT PRIMARY KEY, event_key TEXT NOT NULL UNIQUE, org_id TEXT NOT NULL, storage_id TEXT NOT NULL,
resource_type TEXT NOT NULL, resource_id TEXT NOT NULL
);
CREATE TABLE object_upload_sessions (
id TEXT PRIMARY KEY, org_id TEXT NOT NULL, object_id TEXT NOT NULL, storage_id TEXT NOT NULL, created_by TEXT NOT NULL
);
CREATE TABLE apikey (id TEXT PRIMARY KEY, reference_id TEXT NOT NULL, metadata TEXT);
CREATE TABLE session (id TEXT PRIMARY KEY, user_id TEXT, token TEXT);
CREATE TABLE oauthAccessToken (id TEXT PRIMARY KEY, user_id TEXT, token TEXT);
CREATE TABLE jwks (id TEXT PRIMARY KEY);
CREATE TABLE downloaders (id TEXT PRIMARY KEY, token_jti TEXT NOT NULL UNIQUE, enabled INTEGER NOT NULL, status TEXT NOT NULL);
CREATE TABLE license_bindings (
id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, status TEXT NOT NULL, refresh_token TEXT,
cached_certificate TEXT, cached_certificate_expires_at INTEGER
);
CREATE TABLE system_options (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO organization VALUES ('_org');
INSERT INTO user VALUES ('user-');
INSERT INTO account VALUES ('socialAccount', 'user-', 'access', 'refresh', 'identity', 1, 2, 'openid', NULL);
INSERT INTO storages VALUES ('sto-rage');
INSERT INTO matters VALUES ('matter-', '_org', 'alias_', 'sto-rage', 'objects/matter-/file.bin');
`)
db.exec(`
INSERT INTO shares VALUES ('share_', 'ds_legacy', 'direct', 'active', 'matter-', '_org', 'user-');
INSERT INTO image_hostings VALUES ('share_', '_org', 'active', '_org', 'sto-rage', 'ih/_org/_image.png');
INSERT INTO image_hosting_configs VALUES ('_org', 'verify_token');
INSERT INTO team_invite_links VALUES ('InviteBase62', 'AlreadyBase62');
INSERT INTO notifications VALUES ('notice-', 'user-', 'share_', '{"shareId":"share_","jobId":"job_","token":"ds_legacy","nested":{"matterId":"matter-"}}');
INSERT INTO background_jobs VALUES ('job_', '_org', 'user-', '{"matterIds":["matter-"],"jobId":"job_"}', '{"matterIds":["matter-"]}');
INSERT INTO audit_events VALUES ('event:user_register:user-', NULL, '_org', 'user-', 'user', NULL, 'user', 'user-', '{"storageId":"sto-rage","sessionId":"session_upload_","sourceId":"share_","entitlementId":"entitlement_","externalId":"sto-rage"}');
INSERT INTO resource_changes(scope_id, resource_id, metadata) VALUES ('_org', 'share_', '{"userId":"user-"}');
INSERT INTO apikey VALUES ('api_key', 'user-', '{"workspaceId":"_org"}');
INSERT INTO oauthResource VALUES ('oauthResource1', '_org', 'jwk_bad');
INSERT INTO oauthClientResource VALUES ('client::resource', 'external-client', '_org');
INSERT INTO x402_capacity_purchase_intents VALUES ('intent_', '_org', '_org');
INSERT INTO org_quota_entitlements VALUES ('entitlement_', '_org', '{"grantedBy":"user-","updatedBy":"user-","revokedBy":"user-"}');
INSERT INTO cloud_traffic_reports VALUES ('traffic-row_', '_org', 'sto-rage', 'direct_share', 'share_', 'external-event');
INSERT INTO cloud_traffic_reports VALUES ('TrafficWebdav', '_org', 'sto-rage', 'webdav_download', 'matter-', 'external-webdav');
INSERT INTO cloud_traffic_reports VALUES ('TrafficImage', '_org', 'sto-rage', 'custom_domain_image', 'share_', 'external-image');
INSERT INTO storage_usage_ledger VALUES ('opening:_org:sto-rage', 'opening:_org:sto-rage', '_org', 'sto-rage', 'storage', 'sto-rage');
INSERT INTO object_upload_sessions VALUES ('session_upload_', '_org', 'matter-', 'sto-rage', 'downloader:down_loader');
INSERT INTO session VALUES ('session_bad', 'user-', 'session-token');
INSERT INTO oauthAccessToken VALUES ('access_bad', 'user-', 'oauth-token');
INSERT INTO jwks VALUES ('jwk_bad');
INSERT INTO downloaders VALUES ('down_loader', 'jti_bad', 1, 'online');
INSERT INTO license_bindings VALUES ('binding_', 'instance_bad', 'active', 'secret', 'certificate', 99);
INSERT INTO license_bindings VALUES ('BindingBase62', 'InstanceBase62', 'disconnected', 'stale-secret', 'stale-certificate', 100);
INSERT INTO system_options VALUES ('instance_id', 'instance_bad');
`)
return db
}
describe('ID backfill', () => {
it('shares the complete touched-table inventory with the fresh-database guard', () => {
expect(idBackfillDataTables()).toEqual([...ID_NORMALIZATION_DATA_TABLES].sort())
})
it('dry-runs without mutation and emits D1-sized, idempotent SQL', () => {
const db = fixture()
const plan = createBackfillPlan(db)
expect(plan.before).toMatchObject({
invalidIds: 18,
invalidTokens: 6,
credentialsToInvalidate: 7,
ambiguousRedirectTokens: 0,
})
expect(plan.sql.every((statement) => Buffer.byteLength(statement) <= 100_000)).toBe(true)
expect(plan.sql.length).toBeLessThanOrEqual(47)
expect((db.prepare('SELECT id FROM organization').get() as { id: string }).id).toBe('_org')
db.close()
})
it('rewrites PK/FK, polymorphic and JSON references and invalidates credentials', () => {
const db = fixture()
const objectKeys = {
object: (db.prepare('SELECT object FROM matters').get() as { object: string }).object,
storageKey: (db.prepare('SELECT storage_key AS storageKey FROM image_hostings').get() as { storageKey: string })
.storageKey,
}
const beforeCounts = {
matters: (db.prepare('SELECT COUNT(*) count FROM matters').get() as { count: number }).count,
shares: (db.prepare('SELECT COUNT(*) count FROM shares').get() as { count: number }).count,
}
const after = applyBackfill(db)
expect(after).toMatchObject({ invalidIds: 0, invalidTokens: 0, credentialsToInvalidate: 0 })
expect(db.pragma('foreign_key_check')).toEqual([])
expect(db.prepare('SELECT COUNT(*) count FROM session').get()).toEqual({ count: 0 })
expect(db.prepare('SELECT COUNT(*) count FROM oauthAccessToken').get()).toEqual({ count: 0 })
expect(db.prepare('SELECT COUNT(*) count FROM jwks').get()).toEqual({ count: 0 })
expect(db.prepare('SELECT signing_key_id FROM oauthResource').get()).toEqual({ signing_key_id: null })
expect(db.prepare('SELECT access_token, refresh_token, id_token, scope FROM account').get()).toEqual({
access_token: null,
refresh_token: null,
id_token: null,
scope: null,
})
expect(db.prepare('SELECT enabled, status FROM downloaders').get()).toEqual({ enabled: 0, status: 'offline' })
expect(
db.prepare('SELECT COUNT(*) AS count FROM license_bindings WHERE status != \'disconnected\' OR refresh_token IS NOT NULL OR cached_certificate IS NOT NULL OR cached_certificate_expires_at IS NOT NULL').get(),
).toEqual({ count: 0 })
expect((db.prepare('SELECT instance_id FROM license_bindings').get() as { instance_id: string }).instance_id).toMatch(
/^[A-Za-z0-9]+$/,
)
expect(db.prepare('SELECT COUNT(*) count FROM matters').get()).toEqual({ count: beforeCounts.matters })
expect(db.prepare('SELECT COUNT(*) count FROM shares').get()).toEqual({ count: beforeCounts.shares })
expect(db.prepare('SELECT object FROM matters').get()).toEqual({ object: objectKeys.object })
expect(db.prepare('SELECT storage_key AS storageKey FROM image_hostings').get()).toEqual({
storageKey: objectKeys.storageKey,
})
const share = db.prepare('SELECT id, token, matter_id, org_id, creator_id FROM shares').get() as Record<string, string>
expect(Object.values(share).every((value) => /^[A-Za-z0-9]+$/.test(value))).toBe(true)
expect((db.prepare('SELECT token FROM image_hostings').get() as { token: string }).token).not.toBe(share.token)
expect(db.prepare('SELECT kind, resource_id FROM redirect_token_registry ORDER BY kind').all()).toEqual([
{ kind: 'direct_share', resource_id: share.id },
{ kind: 'image_hosting', resource_id: (db.prepare('SELECT id FROM image_hostings').get() as { id: string }).id },
])
const rotatedValidToken = (db.prepare('SELECT token FROM team_invite_links').get() as { token: string }).token
expect(rotatedValidToken).toMatch(/^[A-Za-z0-9]{32}$/)
expect(rotatedValidToken).not.toBe('AlreadyBase62')
const metadata = JSON.parse((db.prepare('SELECT metadata FROM notifications').get() as { metadata: string }).metadata)
expect(metadata.shareId).toBe(share.id)
expect(metadata.nested.matterId).toBe(share.matter_id)
expect(metadata.token).toBe(share.token)
const job = db.prepare('SELECT id, metadata, result_metadata FROM background_jobs').get() as Record<string, string>
expect(JSON.parse(job.metadata)).toEqual({ matterIds: [share.matter_id], jobId: job.id })
expect(JSON.parse(job.result_metadata)).toEqual({ matterIds: [share.matter_id] })
expect((db.prepare('SELECT event_key FROM audit_events').get() as { event_key: string }).event_key).toBe(
`event:user_register:${share.creator_id}`,
)
const auditMetadata = JSON.parse((db.prepare('SELECT metadata FROM audit_events').get() as { metadata: string }).metadata)
expect(auditMetadata.storageId).toBe(
(db.prepare('SELECT storage_id FROM matters').get() as { storage_id: string }).storage_id,
)
expect(auditMetadata.externalId).toBe('sto-rage')
expect(auditMetadata.sessionId).toBe(
(db.prepare('SELECT id FROM object_upload_sessions').get() as { id: string }).id,
)
expect(auditMetadata.sourceId).toBe(share.id)
const entitlement = db.prepare('SELECT id, metadata FROM org_quota_entitlements').get() as { id: string; metadata: string }
expect(auditMetadata.entitlementId).toBe(entitlement.id)
expect(Object.values(JSON.parse(entitlement.metadata)).every((value) => value === share.creator_id)).toBe(true)
expect((db.prepare('SELECT resource_id FROM oauthClientResource').get() as { resource_id: string }).resource_id).toBe('_org')
expect((db.prepare('SELECT resource_id FROM x402_capacity_purchase_intents').get() as { resource_id: string }).resource_id).toBe('_org')
expect((db.prepare("SELECT source_id FROM cloud_traffic_reports WHERE source = 'direct_share'").get() as { source_id: string }).source_id).toBe(share.id)
expect((db.prepare("SELECT source_id FROM cloud_traffic_reports WHERE source = 'webdav_download'").get() as { source_id: string }).source_id).toBe(share.matter_id)
expect((db.prepare("SELECT source_id FROM cloud_traffic_reports WHERE source = 'custom_domain_image'").get() as { source_id: string }).source_id).toBe(
(db.prepare('SELECT id FROM image_hostings').get() as { id: string }).id,
)
const ledger = db.prepare('SELECT event_key, org_id, storage_id, resource_id FROM storage_usage_ledger').get() as Record<string, string>
expect(Object.values(ledger).every((value) => /^[A-Za-z0-9:]+$/.test(value))).toBe(true)
expect(ledger.event_key).toBe(`opening:${ledger.org_id}:${ledger.storage_id}`)
expect((db.prepare('SELECT created_by FROM object_upload_sessions').get() as { created_by: string }).created_by).toMatch(/^downloader:[A-Za-z0-9]+$/)
expect(() => verifyBackfill(db)).not.toThrow()
expect(db.prepare("SELECT value FROM system_options WHERE key = 'id_normalization_pending_artifact_digest'").get()).toMatchObject({
value: expect.stringMatching(/^[0-9a-f]{64}$/),
})
const second = applyBackfill(db)
expect(second).toMatchObject({ invalidIds: 0, invalidTokens: 0, credentialsToInvalidate: 0 })
db.close()
})
it('executes the complete representative planner artifact through the maintenance executor', async () => {
const db = fixture()
const plan = createBackfillPlan(db)
const batch = await artifact(plan.sql)
await expect(applyIdBackfillArtifact(sqliteD1(db), batch, batch.digest)).resolves.toEqual({
statements: plan.sql.length,
digest: batch.digest,
})
expect(verifyBackfill(db)).toMatchObject({ invalidIds: 0, invalidTokens: 0, credentialsToInvalidate: 0 })
expect(db.prepare('SELECT COUNT(*) AS count FROM redirect_token_registry').get()).toEqual({ count: 2 })
db.close()
})
it('rolls back mapped identifiers while keeping deliberately invalidated credentials absent', () => {
const db = fixture()
applyBackfill(db)
const summary = rollbackBackfill(db)
expect(summary.invalidIds).toBeGreaterThan(0)
expect((db.prepare('SELECT id FROM organization').get() as { id: string }).id).toBe('_org')
expect((db.prepare('SELECT token FROM shares').get() as { token: string }).token).toBe('ds_legacy')
expect((db.prepare('SELECT token FROM team_invite_links').get() as { token: string }).token).toBe('AlreadyBase62')
expect(db.prepare('SELECT token, kind FROM redirect_token_registry ORDER BY kind').all()).toEqual([
{ token: 'ds_legacy', kind: 'direct_share' },
{ token: '_org', kind: 'image_hosting' },
])
expect((db.prepare('SELECT event_key FROM audit_events').get() as { event_key: string }).event_key).toBe(
'event:user_register:user-',
)
expect(JSON.parse((db.prepare('SELECT metadata FROM notifications').get() as { metadata: string }).metadata)).toEqual({
shareId: 'share_',
jobId: 'job_',
token: 'ds_legacy',
nested: { matterId: 'matter-' },
})
expect(db.prepare('SELECT COUNT(*) count FROM session').get()).toEqual({ count: 0 })
expect(
db.prepare("SELECT value FROM system_options WHERE key = 'id_normalization_pending_artifact_digest'").get(),
).toBeUndefined()
db.close()
})
it('rolls back an interrupted transaction completely', () => {
const db = fixture()
const plan = createBackfillPlan(db)
const run = db.transaction(() => {
db.pragma('defer_foreign_keys = ON')
for (const statement of plan.sql.slice(0, Math.ceil(plan.sql.length / 2))) db.exec(statement)
throw new Error('simulated interruption')
})
expect(run).toThrow('simulated interruption')
expect((db.prepare('SELECT id FROM organization').get() as { id: string }).id).toBe('_org')
expect(inspectBackfill(db).invalidIds).toBeGreaterThan(0)
db.close()
})
it('handles an empty migrated database', () => {
const db = new Database(':memory:')
migrate(drizzle(db), { migrationsFolder: 'migrations' })
const plan = createBackfillPlan(db)
expect(plan.sql.length).toBeLessThanOrEqual(47)
expect(applyBackfill(db, plan)).toMatchObject({ invalidIds: 0, invalidTokens: 0 })
db.close()
})
it('coalesces a large set of JSON rewrites within the D1 free-plan query budget', () => {
const db = fixture()
const baselineDocuments = inspectBackfill(db).jsonDocumentsToRewrite
const insert = db.prepare('INSERT INTO notifications VALUES (?, ?, ?, ?)')
db.transaction(() => {
for (let index = 0; index < 1_000; index += 1) {
insert.run(`Notice${index}`, 'user-', 'share_', '{"shareId":"share_","matterId":"matter-"}')
}
})()
expect(inspectBackfill(db).jsonDocumentsToRewrite).toBe(baselineDocuments + 1_000)
const plan = createBackfillPlan(db)
expect(plan.sql.length).toBeLessThanOrEqual(47)
applyBackfill(db, plan)
expect(
db.prepare('SELECT COUNT(*) AS count FROM notifications WHERE instr(metadata, \'"share_"\') > 0').get(),
).toEqual({ count: 0 })
db.close()
})
it('fails before mutation when a snapshot cannot fit the atomic D1 plan', () => {
const db = fixture()
const beforeCount = (
db.prepare("SELECT COUNT(*) AS count FROM notifications WHERE id LIKE 'notice-%'").get() as { count: number }
).count
const insert = db.prepare('INSERT INTO notifications VALUES (?, ?, NULL, NULL)')
db.transaction(() => {
for (let index = 0; index < 24_000; index += 1) insert.run(`notice-${index}`, 'user-')
})()
expect(() => createBackfillPlan(db)).toThrow(/d1_query_limit_exceeded:\d+:47/)
expect(db.prepare("SELECT COUNT(*) AS count FROM notifications WHERE id LIKE 'notice-%'").get()).toEqual({
count: beforeCount + 24_000,
})
expect(db.prepare("SELECT name FROM sqlite_master WHERE name = '_zpan_id_backfill_map'").get()).toBeUndefined()
db.close()
})
it('transitions the pending digest to completion and applied-digest markers', () => {
const db = fixture()
applyBackfill(db)
const pending = db
.prepare("SELECT value FROM system_options WHERE key = 'id_normalization_pending_artifact_digest'")
.get() as { value: string }
finalizeBackfill(db)
expect(db.prepare("SELECT value FROM system_options WHERE key = 'id_normalization_version'").get()).toEqual({
value: '1',
})
expect(db.prepare("SELECT value FROM system_options WHERE key = 'id_normalization_applied_artifact_digest'").get()).toEqual({
value: pending.value,
})
expect(
db.prepare("SELECT value FROM system_options WHERE key = 'id_normalization_pending_artifact_digest'").get(),
).toBeUndefined()
expect(() => createBackfillPlan(db)).toThrow('id_backfill_already_finalized:1')
expect(() => finalizeBackfill(db)).toThrow('id_backfill_already_finalized:1')
db.close()
})
it('allows runtime bootstrap during the local observation window', async () => {
const sqlite = new Database(':memory:')
migrate(drizzle(sqlite), { migrationsFolder: 'migrations' })
sqlite.exec(`
INSERT INTO organization (id, name, slug, created_at) VALUES ('legacy_org', 'Legacy', 'legacy', 1);
`)
applyBackfill(sqlite)
await expect(assertIdIntegrity(drizzle(sqlite))).resolves.toBeUndefined()
sqlite.close()
})
})
+725
View File
@@ -0,0 +1,725 @@
import { createHash } from 'node:crypto'
import type Database from 'better-sqlite3'
import {
ID_NORMALIZATION_DATA_TABLES,
INVALIDATED_CREDENTIAL_TABLES,
OWNED_ID_TABLES,
} from '../shared/id-normalization-inventory'
import { DEFAULT_ID_LENGTH, generateToken, isBase62 } from '../shared/ids'
const MAP_TABLE = '_zpan_id_backfill_map'
const ID_NAMESPACE = 'id'
const COMPLETION_KEY = 'id_normalization_version'
const COMPLETION_VERSION = '1'
const PENDING_DIGEST_KEY = 'id_normalization_pending_artifact_digest'
const APPLIED_DIGEST_KEY = 'id_normalization_applied_artifact_digest'
const D1_MAX_ARTIFACT_STATEMENTS = 47
const REDIRECT_TOKEN_NAMESPACES = new Set(['token:shares.token', 'token:image_hostings.token'])
const LOCAL_REFERENCE_COLUMNS = [
['matters', 'org_id'], ['matters', 'storage_id'], ['org_quotas', 'org_id'],
['account', 'user_id'], ['member', 'organization_id'], ['member', 'user_id'],
['invitation', 'organization_id'], ['invitation', 'inviter_id'],
['invite_codes', 'created_by'], ['invite_codes', 'used_by'],
['team_invite_links', 'organization_id'], ['team_invite_links', 'inviter_id'],
['shares', 'matter_id'], ['shares', 'org_id'], ['shares', 'creator_id'],
['share_recipients', 'share_id'], ['share_recipients', 'recipient_user_id'],
['notifications', 'user_id'], ['notifications', 'ref_id'],
['image_hosting_configs', 'org_id'], ['image_hostings', 'org_id'], ['image_hostings', 'storage_id'],
['apikey', 'reference_id'], ['site_invitations', 'invited_by'], ['site_invitations', 'accepted_by'],
['site_invitations', 'revoked_by'], ['announcements', 'created_by'],
['org_quota_entitlements', 'org_id'], ['cloud_traffic_reports', 'org_id'],
['cloud_traffic_reports', 'storage_id'], ['background_jobs', 'org_id'], ['background_jobs', 'user_id'],
['background_jobs', 'retried_from_job_id'], ['webdav_dead_properties', 'org_id'], ['webdav_locks', 'org_id'],
['download_tasks', 'org_id'], ['download_tasks', 'created_by_user_id'],
['download_tasks', 'assigned_downloader_id'], ['download_tasks', 'result_object_id'],
['remote_download_usage_reports', 'org_id'], ['remote_download_usage_reports', 'downloader_id'],
['remote_download_usage_reports', 'task_id'], ['object_upload_sessions', 'org_id'],
['object_upload_sessions', 'object_id'], ['object_upload_sessions', 'storage_id'],
['object_upload_sessions', 'created_by'],
['downloaders', 'created_by'], ['stats_rollups_hourly', 'org_id'], ['audit_events', 'org_id'],
['audit_events', 'user_id'], ['storage_usage_ledger', 'org_id'], ['storage_usage_ledger', 'storage_id'],
['storage_usage_breakdowns', 'org_id'], ['resource_changes', 'scope_id'],
['resource_changes', 'resource_id'], ['oauthClient', 'user_id'], ['x402_capacity_purchase_intents', 'org_id'],
] as const
const GENERAL_LOCAL_JSON_ID_KEYS = [
'shareId', 'matterId', 'storageId', 'userId', 'workspaceId', 'orgId', 'organizationId',
'downloaderId', 'taskId', 'objectId', 'imageId', 'resultObjectId', 'recipientUserId', 'scopeId',
'jobId', 'sessionId', 'entitlementId',
] as const
export const TOKEN_COLUMNS = [
{ table: 'matters', column: 'alias', length: 11 },
{ table: 'invite_codes', column: 'code', length: 8 },
{ table: 'site_invitations', column: 'token', length: 33 },
{ table: 'team_invite_links', column: 'token', length: 32 },
{ table: 'shares', column: 'token', length: 11 },
{ table: 'image_hostings', column: 'token', length: 12 },
{ table: 'image_hosting_configs', column: 'verification_token', length: 33 },
{ table: 'downloaders', column: 'token_jti', length: DEFAULT_ID_LENGTH },
] as const
const JSON_COLUMNS = [
{ table: 'notifications', key: 'id', columns: ['metadata'], idKeys: ['shareId', 'matterId', 'jobId'], arrayIdKeys: [], shareTokenKeys: ['token'] },
{ table: 'background_jobs', key: 'id', columns: ['metadata', 'result_metadata'], idKeys: [...GENERAL_LOCAL_JSON_ID_KEYS], arrayIdKeys: ['matterIds'], shareTokenKeys: [] },
{ table: 'download_tasks', key: 'id', columns: ['events'], idKeys: [...GENERAL_LOCAL_JSON_ID_KEYS], arrayIdKeys: ['matterIds'], shareTokenKeys: [] },
{ table: 'audit_events', key: 'id', columns: ['metadata'], idKeys: [...GENERAL_LOCAL_JSON_ID_KEYS, 'sourceId'], arrayIdKeys: ['matterIds'], shareTokenKeys: [] },
{ table: 'resource_changes', key: 'sequence', columns: ['metadata'], idKeys: [...GENERAL_LOCAL_JSON_ID_KEYS], arrayIdKeys: ['matterIds'], shareTokenKeys: [] },
{ table: 'stats_rollups_hourly', key: 'id', columns: ['metadata'], idKeys: [...GENERAL_LOCAL_JSON_ID_KEYS], arrayIdKeys: [], shareTokenKeys: [] },
{ table: 'org_quota_entitlements', key: 'id', columns: ['metadata'], idKeys: ['grantedBy', 'updatedBy', 'revokedBy'], arrayIdKeys: [], shareTokenKeys: [] },
{ table: 'apikey', key: 'id', columns: ['metadata'], idKeys: ['workspaceId', 'orgId', 'userId'], arrayIdKeys: [], shareTokenKeys: [] },
] as const
export function idBackfillDataTables(): string[] {
return [
...new Set([
...OWNED_ID_TABLES,
...INVALIDATED_CREDENTIAL_TABLES,
...TOKEN_COLUMNS.map(({ table }) => table),
...LOCAL_REFERENCE_COLUMNS.map(([table]) => table),
...JSON_COLUMNS.map(({ table }) => table),
'redirect_token_registry',
]),
].sort()
}
if (idBackfillDataTables().join('\0') !== [...ID_NORMALIZATION_DATA_TABLES].sort().join('\0')) {
throw new Error('id_normalization_inventory_mismatch')
}
export interface BackfillMapping {
namespace: string
oldValue: string
newValue: string
}
export interface BackfillSummary {
invalidIds: number
invalidTokens: number
mappings: number
tokenRotations: number
credentialsToInvalidate: number
jsonDocumentsToRewrite: number
ambiguousRedirectTokens: number
}
export interface BackfillPlan {
sql: string[]
mappings: BackfillMapping[]
before: BackfillSummary
}
function ident(value: string): string {
return `"${value.replaceAll('"', '""')}"`
}
function literal(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
function tableExists(db: Database.Database, table: string): boolean {
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table))
}
function indexExists(db: Database.Database, index: string): boolean {
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?").get(index))
}
function columns(db: Database.Database, table: string): string[] {
return (db.prepare(`PRAGMA table_info(${ident(table)})`).all() as Array<{ name: string }>).map((row) => row.name)
}
function tableHasRows(db: Database.Database, table: string, where = ''): boolean {
if (!tableExists(db, table)) return false
return Boolean(db.prepare(`SELECT 1 FROM ${ident(table)}${where ? ` WHERE ${where}` : ''} LIMIT 1`).get())
}
function columnHasMapping(
db: Database.Database,
table: string,
column: string,
mappings: readonly BackfillMapping[],
namespace = ID_NAMESPACE,
): boolean {
if (!tableExists(db, table) || !columns(db, table).includes(column)) return false
const oldValues = new Set(mappings.filter((entry) => entry.namespace === namespace).map((entry) => entry.oldValue))
if (oldValues.size === 0) return false
const values = db
.prepare(`SELECT DISTINCT ${ident(column)} AS value FROM ${ident(table)} WHERE ${ident(column)} IS NOT NULL`)
.all() as Array<{ value: unknown }>
return values.some(({ value }) => typeof value === 'string' && oldValues.has(value))
}
function existingMappings(db: Database.Database): BackfillMapping[] {
if (!tableExists(db, MAP_TABLE)) return []
return db
.prepare(`SELECT namespace, old_value AS oldValue, new_value AS newValue FROM ${ident(MAP_TABLE)}`)
.all() as BackfillMapping[]
}
function assertNotFinalized(db: Database.Database): void {
if (!tableExists(db, 'system_options')) return
const marker = db.prepare('SELECT value FROM system_options WHERE key = ?').get(COMPLETION_KEY) as
| { value: string }
| undefined
if (marker) throw new Error(`id_backfill_already_finalized:${marker.value}`)
}
export function backfillPlanDigest(plan: Pick<BackfillPlan, 'sql'>): string {
return createHash('sha256').update(JSON.stringify(plan.sql)).digest('hex')
}
export function pendingBackfillDigest(db: Database.Database): string | undefined {
if (!tableExists(db, 'system_options')) return undefined
return (
db.prepare('SELECT value FROM system_options WHERE key = ?').get(PENDING_DIGEST_KEY) as
| { value: string }
| undefined
)?.value
}
function invalidValues(db: Database.Database, table: string, column: string): string[] {
if (!tableExists(db, table) || !columns(db, table).includes(column)) return []
const rows = db.prepare(`SELECT DISTINCT ${ident(column)} AS value FROM ${ident(table)}`).all() as Array<{
value: unknown
}>
return rows.flatMap(({ value }) => (typeof value === 'string' && !isBase62(value) ? [value] : []))
}
function collectMappings(db: Database.Database): BackfillMapping[] {
const persisted = existingMappings(db)
const byKey = new Map(persisted.map((entry) => [`${entry.namespace}\0${entry.oldValue}`, entry]))
const used = new Map<string, Set<string>>()
const reserve = (namespace: string, value: string): void => {
const values = used.get(namespace) ?? new Set<string>()
values.add(value)
used.set(namespace, values)
if (REDIRECT_TOKEN_NAMESPACES.has(namespace)) {
const redirectValues = used.get('redirect-token') ?? new Set<string>()
redirectValues.add(value)
used.set('redirect-token', redirectValues)
}
}
for (const entry of persisted) reserve(entry.namespace, entry.newValue)
for (const table of OWNED_ID_TABLES) {
if (!tableExists(db, table) || !columns(db, table).includes('id')) continue
const rows = db.prepare(`SELECT id FROM ${ident(table)}`).all() as Array<{ id: string }>
for (const row of rows) if (isBase62(row.id)) reserve(ID_NAMESPACE, row.id)
}
const uniqueValue = (namespace: string, length: number): string => {
let candidate = generateToken(length)
while (used.get(namespace)?.has(candidate) || (REDIRECT_TOKEN_NAMESPACES.has(namespace) && used.get('redirect-token')?.has(candidate))) {
candidate = generateToken(length)
}
reserve(namespace, candidate)
if (REDIRECT_TOKEN_NAMESPACES.has(namespace)) reserve('redirect-token', candidate)
return candidate
}
for (const table of OWNED_ID_TABLES) {
for (const oldValue of invalidValues(db, table, 'id')) {
const key = `${ID_NAMESPACE}\0${oldValue}`
if (!byKey.has(key)) {
byKey.set(key, { namespace: ID_NAMESPACE, oldValue, newValue: uniqueValue(ID_NAMESPACE, DEFAULT_ID_LENGTH) })
}
}
}
for (const token of TOKEN_COLUMNS) {
const namespace = `token:${token.table}.${token.column}`
const settledValues = new Set(persisted.filter((entry) => entry.namespace === namespace).map((entry) => entry.newValue))
if (tableExists(db, token.table)) {
const rows = db.prepare(`SELECT ${ident(token.column)} AS value FROM ${ident(token.table)}`).all() as Array<{
value: unknown
}>
const changed: Array<{ rowKey: string | number; document: string }> = []
for (const row of rows) {
if (typeof row.value !== 'string') continue
reserve(namespace, row.value)
if (settledValues.has(row.value)) continue
const key = `${namespace}\0${row.value}`
if (!byKey.has(key)) {
byKey.set(key, { namespace, oldValue: row.value, newValue: uniqueValue(namespace, token.length) })
}
}
}
}
if (tableExists(db, 'system_options')) {
const row = db.prepare("SELECT value FROM system_options WHERE key = 'instance_id'").get() as
| { value: string }
| undefined
if (row && !isBase62(row.value)) {
const namespace = 'token:system_options.instance_id'
const key = `${namespace}\0${row.value}`
if (!byKey.has(key)) {
byKey.set(key, { namespace, oldValue: row.value, newValue: uniqueValue(namespace, DEFAULT_ID_LENGTH) })
}
}
}
return [...byKey.values()]
}
function rewriteJson(
value: unknown,
replacement: ReadonlyMap<string, string>,
shareTokenReplacement: ReadonlyMap<string, string>,
idKeys: ReadonlySet<string>,
arrayIdKeys: ReadonlySet<string>,
shareTokenKeys: ReadonlySet<string>,
): unknown {
if (Array.isArray(value)) {
return value.map((item) =>
rewriteJson(item, replacement, shareTokenReplacement, idKeys, arrayIdKeys, shareTokenKeys),
)
}
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
idKeys.has(key) && typeof item === 'string'
? replacement.get(item) ?? item
: arrayIdKeys.has(key) && Array.isArray(item)
? item.map((entry) => (typeof entry === 'string' ? replacement.get(entry) ?? entry : entry))
: shareTokenKeys.has(key) && typeof item === 'string'
? shareTokenReplacement.get(item) ?? item
: rewriteJson(item, replacement, shareTokenReplacement, idKeys, arrayIdKeys, shareTokenKeys),
]),
)
}
function jsonUpdates(
db: Database.Database,
mappings: BackfillMapping[],
): { statements: string[]; documentCount: number } {
const replacement = new Map<string, string>()
for (const { oldValue, newValue } of mappings.filter(({ namespace }) => namespace === ID_NAMESPACE)) {
const existing = replacement.get(oldValue)
if (existing && existing !== newValue) throw new Error('ambiguous_embedded_mapping')
replacement.set(oldValue, newValue)
}
const shareTokenReplacement = new Map(
mappings
.filter(({ namespace }) => namespace === 'token:shares.token')
.map(({ oldValue, newValue }) => [oldValue, newValue]),
)
const updates: string[] = []
let documentCount = 0
for (const config of JSON_COLUMNS) {
const idKeys = new Set<string>(config.idKeys)
const arrayIdKeys = new Set<string>(config.arrayIdKeys)
const shareTokenKeys = new Set<string>(config.shareTokenKeys)
if (!tableExists(db, config.table)) continue
const available = new Set(columns(db, config.table))
if (!available.has(config.key)) continue
for (const column of config.columns) {
if (!available.has(column)) continue
const rows = db
.prepare(
`SELECT ${ident(config.key)} AS rowKey, ${ident(column)} AS document FROM ${ident(config.table)} WHERE ${ident(column)} IS NOT NULL`,
)
.all() as Array<{ rowKey: string | number; document: string }>
const changed: Array<{ rowKey: string | number; document: string }> = []
for (const row of rows) {
let parsed: unknown
try {
parsed = JSON.parse(row.document)
} catch {
throw new Error(`invalid_json:${config.table}.${column}`)
}
const rewritten = JSON.stringify(
rewriteJson(parsed, replacement, shareTokenReplacement, idKeys, arrayIdKeys, shareTokenKeys),
)
if (rewritten === row.document) continue
changed.push({ rowKey: row.rowKey, document: rewritten })
documentCount += 1
}
let batch: typeof changed = []
const build = (entries: typeof changed): string => {
const cases = entries
.map(({ rowKey, document }) => {
const key = typeof rowKey === 'number' ? String(rowKey) : literal(rowKey)
return `WHEN ${key} THEN ${literal(document)}`
})
.join(' ')
const keys = entries
.map(({ rowKey }) => (typeof rowKey === 'number' ? String(rowKey) : literal(rowKey)))
.join(', ')
return `UPDATE ${ident(config.table)} SET ${ident(column)} = CASE ${ident(config.key)} ${cases} END WHERE ${ident(config.key)} IN (${keys});`
}
for (const entry of changed) {
const candidate = [...batch, entry]
if (batch.length > 0 && Buffer.byteLength(build(candidate), 'utf8') > 95_000) {
updates.push(build(batch))
batch = [entry]
} else {
batch = candidate
}
}
if (batch.length > 0) updates.push(build(batch))
}
}
return { statements: updates, documentCount }
}
function mappingTableSql(): string {
return `CREATE TABLE IF NOT EXISTS ${ident(MAP_TABLE)} (namespace TEXT NOT NULL, old_value TEXT NOT NULL, new_value TEXT NOT NULL, PRIMARY KEY (namespace, old_value), UNIQUE (namespace, new_value));`
}
function mapExpression(column: string, namespace = ID_NAMESPACE): string {
return `(SELECT new_value FROM ${ident(MAP_TABLE)} WHERE namespace = ${literal(namespace)} AND old_value = ${ident(column)})`
}
function mapPredicate(column: string, namespace = ID_NAMESPACE): string {
return `EXISTS (SELECT 1 FROM ${ident(MAP_TABLE)} WHERE namespace = ${literal(namespace)} AND old_value = ${ident(column)})`
}
function updateSql(db: Database.Database, mappings: BackfillMapping[]): string[] {
const sql: string[] = ['PRAGMA defer_foreign_keys = ON;', mappingTableSql()]
for (let offset = 0; offset < mappings.length; offset += 500) {
const values = mappings
.slice(offset, offset + 500)
.map((entry) => `(${literal(entry.namespace)}, ${literal(entry.oldValue)}, ${literal(entry.newValue)})`)
.join(', ')
sql.push(
`INSERT OR IGNORE INTO ${ident(MAP_TABLE)} (namespace, old_value, new_value) VALUES ${values};`,
)
}
sql.push(...jsonUpdates(db, mappings).statements)
if (!tableExists(db, 'audit_events') || !columns(db, 'audit_events').includes('event_key')) {
throw new Error('required_migration_missing:audit_events.event_key')
}
if (!tableExists(db, 'redirect_token_registry')) {
throw new Error('required_migration_missing:redirect_token_registry')
}
if (!indexExists(db, 'redirect_token_registry_kind_resource_id_unique')) {
throw new Error('required_migration_missing:redirect_token_registry_kind_resource_id_unique')
}
if (tableHasRows(db, 'audit_events', "event_key IS NULL AND (id LIKE 'event:%' OR id LIKE 'audit:%')")) {
sql.push("UPDATE audit_events SET event_key = id WHERE event_key IS NULL AND (id LIKE 'event:%' OR id LIKE 'audit:%');")
}
const tableUpdates = new Map<string, { assignments: string[]; predicates: string[] }>()
const addUpdate = (table: string, column: string, expression: string, predicate: string): void => {
const update = tableUpdates.get(table) ?? { assignments: [], predicates: [] }
update.assignments.push(`${ident(column)} = CASE WHEN ${predicate} THEN ${expression} ELSE ${ident(column)} END`)
update.predicates.push(predicate)
tableUpdates.set(table, update)
}
const addMappedUpdate = (table: string, column: string, namespace = ID_NAMESPACE, extra = ''): void => {
if (!columnHasMapping(db, table, column, mappings, namespace)) return
const predicate = `${extra ? `${extra} AND ` : ''}${mapPredicate(column, namespace)}`
addUpdate(table, column, mapExpression(column, namespace), predicate)
}
for (const [table, column] of LOCAL_REFERENCE_COLUMNS) addMappedUpdate(table, column)
addMappedUpdate(
'audit_events',
'target_id',
ID_NAMESPACE,
"target_type IN ('team','user','file','folder','share','image','remote_download','quota')",
)
addMappedUpdate(
'audit_events',
'actor_ref',
ID_NAMESPACE,
"actor_type IN ('api_key','downloader','task-upload')",
)
addMappedUpdate(
'storage_usage_ledger',
'resource_id',
ID_NAMESPACE,
"resource_type IN ('matter','image_hosting','storage')",
)
addMappedUpdate(
'cloud_traffic_reports',
'source_id',
ID_NAMESPACE,
"source IN ('object_download','webdav_download','direct_share','landing_share','image_hosting','custom_domain_image')",
)
const idOldValues = mappings.filter(({ namespace }) => namespace === ID_NAMESPACE).map(({ oldValue }) => oldValue)
for (const table of ['audit_events', 'storage_usage_ledger'].filter((table) => {
if (!tableHasRows(db, table)) return false
const eventKeys = db
.prepare(`SELECT COALESCE(event_key, id) AS value FROM ${ident(table)}`)
.all() as Array<{ value: string }>
return eventKeys.some(({ value }) => idOldValues.some((oldValue) => value.includes(oldValue)))
})) {
sql.push(rewriteStructuredReferencesSql(table, 'event_key'))
}
if (tableHasRows(db, 'object_upload_sessions', "created_by LIKE 'downloader:%'")) {
const predicate = `created_by LIKE 'downloader:%' AND EXISTS (
SELECT 1 FROM ${ident(MAP_TABLE)}
WHERE namespace = ${literal(ID_NAMESPACE)} AND old_value = substr(object_upload_sessions.created_by, 12)
)`
addUpdate(
'object_upload_sessions',
'created_by',
`'downloader:' || (
SELECT new_value FROM ${ident(MAP_TABLE)}
WHERE namespace = ${literal(ID_NAMESPACE)} AND old_value = substr(object_upload_sessions.created_by, 12)
)
`,
predicate,
)
}
for (const table of OWNED_ID_TABLES) {
addMappedUpdate(table, 'id')
}
for (const token of TOKEN_COLUMNS) {
const namespace = `token:${token.table}.${token.column}`
addMappedUpdate(token.table, token.column, namespace)
}
addMappedUpdate('system_options', 'value', 'token:system_options.instance_id', "key = 'instance_id'")
addMappedUpdate('license_bindings', 'instance_id', 'token:system_options.instance_id')
if (tableHasRows(db, 'downloaders', 'enabled != 0')) {
addUpdate('downloaders', 'enabled', '0', 'enabled != 0')
addUpdate('downloaders', 'status', "'offline'", 'enabled != 0')
}
if (tableHasRows(db, 'license_bindings')) {
const available = new Set(columns(db, 'license_bindings'))
addUpdate('license_bindings', 'status', "'disconnected'", '1')
for (const column of ['refresh_token', 'cached_certificate', 'cached_certificate_expires_at']) {
if (available.has(column)) addUpdate('license_bindings', column, 'NULL', '1')
}
}
if (tableHasRows(db, 'account')) {
const available = new Set(columns(db, 'account'))
const credentialColumns = [
'access_token',
'refresh_token',
'id_token',
'access_token_expires_at',
'refresh_token_expires_at',
'scope',
].filter((column) => available.has(column))
for (const column of credentialColumns) addUpdate('account', column, 'NULL', '1')
}
if (tableHasRows(db, 'oauthResource', 'signing_key_id IS NOT NULL')) {
addUpdate('oauthResource', 'signing_key_id', 'NULL', 'signing_key_id IS NOT NULL')
}
for (const [table, update] of tableUpdates) {
sql.push(
`UPDATE ${ident(table)} SET ${update.assignments.join(', ')} WHERE ${update.predicates.map((predicate) => `(${predicate})`).join(' OR ')};`,
)
}
if (tableExists(db, 'redirect_token_registry')) {
if (tableHasRows(db, 'redirect_token_registry')) sql.push('DELETE FROM redirect_token_registry;')
if (tableHasRows(db, 'shares', "kind = 'direct'")) {
sql.push("INSERT INTO redirect_token_registry (token, kind, resource_id) SELECT token, 'direct_share', id FROM shares WHERE kind = 'direct';")
}
if (tableHasRows(db, 'image_hostings')) {
sql.push("INSERT INTO redirect_token_registry (token, kind, resource_id) SELECT token, 'image_hosting', id FROM image_hostings;")
}
}
for (const table of INVALIDATED_CREDENTIAL_TABLES) {
if (tableHasRows(db, table)) sql.push(`DELETE FROM ${ident(table)};`)
}
return sql
}
function rewriteStructuredReferencesSql(table: string, column: string): string {
const delimited = `namespace = ${literal(ID_NAMESPACE)} AND instr(rewritten.value, ':' || old_value || ':') > 0`
const suffixed = `namespace = ${literal(ID_NAMESPACE)} AND substr(rewritten.value, -length(old_value) - 1) = ':' || old_value`
return `WITH RECURSIVE rewritten(row_key, value, step) AS (
SELECT rowid, ${ident(column)}, 0 FROM ${ident(table)} WHERE ${ident(column)} IS NOT NULL
UNION ALL
SELECT row_key,
CASE
WHEN EXISTS (SELECT 1 FROM ${ident(MAP_TABLE)} WHERE ${delimited}) THEN replace(
value,
':' || (SELECT old_value FROM ${ident(MAP_TABLE)} WHERE ${delimited} LIMIT 1) || ':',
':' || (SELECT new_value FROM ${ident(MAP_TABLE)} WHERE ${delimited} LIMIT 1) || ':'
)
WHEN EXISTS (SELECT 1 FROM ${ident(MAP_TABLE)} WHERE ${suffixed}) THEN
substr(value, 1, length(value) - length((SELECT old_value FROM ${ident(MAP_TABLE)} WHERE ${suffixed} LIMIT 1))) ||
(SELECT new_value FROM ${ident(MAP_TABLE)} WHERE ${suffixed} LIMIT 1)
ELSE value
END,
step + 1
FROM rewritten WHERE step < 8
)
UPDATE ${ident(table)} SET ${ident(column)} = (
SELECT value FROM rewritten WHERE row_key = ${ident(table)}.rowid ORDER BY step DESC LIMIT 1
) WHERE ${ident(column)} IS NOT NULL;`
}
function countRows(db: Database.Database, table: string): number {
if (!tableExists(db, table)) return 0
return (db.prepare(`SELECT COUNT(*) AS count FROM ${ident(table)}`).get() as { count: number }).count
}
function countInvalid(db: Database.Database, table: string, column: string): number {
return invalidValues(db, table, column).length
}
function preservedRowCounts(db: Database.Database): Map<string, number> {
const invalidated = new Set<string>(INVALIDATED_CREDENTIAL_TABLES)
const derived = new Set(['redirect_token_registry'])
const rows = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
.all() as Array<{ name: string }>
return new Map(
rows
.filter(({ name }) => name !== MAP_TABLE && !invalidated.has(name) && !derived.has(name))
.map(({ name }) => [name, countRows(db, name)]),
)
}
function assertPreservedRowCounts(db: Database.Database, before: ReadonlyMap<string, number>): void {
for (const [table, count] of before) {
const after = countRows(db, table)
if (after !== count) throw new Error(`row_count_changed:${table}:${count}:${after}`)
}
}
export function inspectBackfill(db: Database.Database): BackfillSummary {
const invalidIds = OWNED_ID_TABLES.reduce((count, table) => count + countInvalid(db, table, 'id'), 0)
let invalidTokens = TOKEN_COLUMNS.reduce((count, token) => count + countInvalid(db, token.table, token.column), 0)
if (tableExists(db, 'system_options')) {
const instance = db.prepare("SELECT value FROM system_options WHERE key = 'instance_id'").get() as
| { value: string }
| undefined
if (instance && !isBase62(instance.value)) invalidTokens += 1
}
let credentialsToInvalidate = INVALIDATED_CREDENTIAL_TABLES.reduce(
(count, table) => count + countRows(db, table),
0,
)
if (tableExists(db, 'account')) {
const available = new Set(columns(db, 'account'))
const credentialColumns = ['access_token', 'refresh_token', 'id_token'].filter((column) => available.has(column))
if (credentialColumns.length > 0) {
credentialsToInvalidate += (
db
.prepare(`SELECT COUNT(*) AS count FROM account WHERE ${credentialColumns.map((column) => `${ident(column)} IS NOT NULL`).join(' OR ')}`)
.get() as { count: number }
).count
}
}
if (tableExists(db, 'downloaders') && columns(db, 'downloaders').includes('enabled')) {
credentialsToInvalidate += (
db.prepare('SELECT COUNT(*) AS count FROM downloaders WHERE enabled != 0').get() as { count: number }
).count
}
if (tableExists(db, 'license_bindings') && columns(db, 'license_bindings').includes('status')) {
const available = new Set(columns(db, 'license_bindings'))
const credentialColumns = ['refresh_token', 'cached_certificate', 'cached_certificate_expires_at'].filter((column) =>
available.has(column),
)
const conditions = ["status != 'disconnected'", ...credentialColumns.map((column) => `${ident(column)} IS NOT NULL`)]
credentialsToInvalidate += (
db.prepare(`SELECT COUNT(*) AS count FROM license_bindings WHERE ${conditions.join(' OR ')}`).get() as { count: number }
).count
}
const mappings = existingMappings(db).length
const jsonDocumentsToRewrite = jsonUpdates(db, collectMappings(db)).documentCount
let ambiguousRedirectTokens = 0
if (tableExists(db, 'shares') && tableExists(db, 'image_hostings')) {
ambiguousRedirectTokens = (
db
.prepare(
"SELECT COUNT(*) AS count FROM shares s INNER JOIN image_hostings i ON i.token = s.token WHERE s.kind = 'direct' AND s.status = 'active' AND i.status = 'active'",
)
.get() as { count: number }
).count
}
const tokenRotations = existingMappings(db).filter(({ namespace }) => namespace.startsWith('token:')).length
return { invalidIds, invalidTokens, mappings, tokenRotations, credentialsToInvalidate, jsonDocumentsToRewrite, ambiguousRedirectTokens }
}
export function createBackfillPlan(db: Database.Database): BackfillPlan {
assertNotFinalized(db)
const mappings = collectMappings(db)
const before = {
...inspectBackfill(db),
mappings: mappings.length,
tokenRotations: mappings.filter(({ namespace }) => namespace.startsWith('token:')).length,
}
const sql = updateSql(db, mappings)
if (sql.length > D1_MAX_ARTIFACT_STATEMENTS) {
throw new Error(`d1_query_limit_exceeded:${sql.length}:${D1_MAX_ARTIFACT_STATEMENTS}`)
}
const oversized = sql.find((statement) => Buffer.byteLength(statement, 'utf8') > 100_000)
if (oversized) throw new Error('d1_statement_limit_exceeded')
return { sql, mappings, before }
}
export function verifyBackfill(db: Database.Database): BackfillSummary {
const summary = inspectBackfill(db)
const integrity = db.pragma('integrity_check') as Array<{ integrity_check: string }>
if (integrity.length !== 1 || integrity[0]?.integrity_check !== 'ok') {
throw new Error(`integrity_check_failed:${integrity.length}`)
}
const foreignKeys = db.pragma('foreign_key_check') as unknown[]
if (foreignKeys.length > 0) throw new Error(`foreign_key_check_failed:${foreignKeys.length}`)
if (summary.invalidIds > 0) throw new Error(`invalid_ids_remaining:${summary.invalidIds}`)
if (summary.invalidTokens > 0) throw new Error(`invalid_tokens_remaining:${summary.invalidTokens}`)
if (summary.ambiguousRedirectTokens > 0) {
throw new Error(`ambiguous_redirect_tokens:${summary.ambiguousRedirectTokens}`)
}
return summary
}
export function applyBackfill(db: Database.Database, requestedPlan?: BackfillPlan): BackfillSummary {
assertNotFinalized(db)
const pendingDigest = pendingBackfillDigest(db)
if (pendingDigest) {
if (requestedPlan && backfillPlanDigest(requestedPlan) !== pendingDigest) {
throw new Error(`id_backfill_different_artifact_pending:${pendingDigest}`)
}
return verifyBackfill(db)
}
const plan = requestedPlan ?? createBackfillPlan(db)
const digest = backfillPlanDigest(plan)
const rowCounts = preservedRowCounts(db)
const run = db.transaction(() => {
db.pragma('defer_foreign_keys = ON')
for (const statement of plan.sql) db.exec(statement)
const summary = verifyBackfill(db)
assertPreservedRowCounts(db, rowCounts)
db.prepare('INSERT INTO system_options (key, value) VALUES (?, ?)').run(PENDING_DIGEST_KEY, digest)
return summary
})
return run()
}
export function rollbackBackfill(db: Database.Database): BackfillSummary {
assertNotFinalized(db)
if (!tableExists(db, MAP_TABLE)) throw new Error('backfill_mapping_missing')
const rowCounts = preservedRowCounts(db)
const mappings = existingMappings(db).map((entry) => ({
namespace: entry.namespace,
oldValue: entry.newValue,
newValue: entry.oldValue,
}))
const plan = { sql: updateSql(db, mappings), mappings, before: inspectBackfill(db) }
plan.sql.splice(1, 0, `DROP TABLE ${ident(MAP_TABLE)};`)
const run = db.transaction(() => {
db.pragma('defer_foreign_keys = ON')
for (const statement of plan.sql) db.exec(statement)
db.exec(`DROP TABLE ${ident(MAP_TABLE)}`)
const foreignKeys = db.pragma('foreign_key_check') as unknown[]
if (foreignKeys.length > 0) throw new Error(`foreign_key_check_failed:${foreignKeys.length}`)
assertPreservedRowCounts(db, rowCounts)
db.prepare('DELETE FROM system_options WHERE key = ?').run(PENDING_DIGEST_KEY)
return inspectBackfill(db)
})
return run()
}
export function finalizeBackfill(db: Database.Database): void {
assertNotFinalized(db)
if (!tableExists(db, 'system_options')) throw new Error('system_options_missing')
const pendingDigest = pendingBackfillDigest(db)
if (!pendingDigest || !/^[0-9a-f]{64}$/.test(pendingDigest)) throw new Error('id_backfill_pending_digest_missing')
const run = db.transaction(() => {
verifyBackfill(db)
if (!tableExists(db, MAP_TABLE)) throw new Error('backfill_mapping_missing')
db.prepare('INSERT INTO system_options (key, value) VALUES (?, ?)').run(COMPLETION_KEY, COMPLETION_VERSION)
db.prepare('INSERT INTO system_options (key, value) VALUES (?, ?)').run(APPLIED_DIGEST_KEY, pendingDigest)
db.prepare('DELETE FROM system_options WHERE key = ?').run(PENDING_DIGEST_KEY)
db.exec(`DROP TABLE ${ident(MAP_TABLE)}`)
})
run()
}
+27
View File
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
const roots = ['server', 'shared', 'src', 'workers', 'scripts']
const files = execFileSync('git', ['ls-files', ...roots], { encoding: 'utf8' })
.trim()
.split('\n')
.filter((file) => file && !/\.(?:test|cf-test|libsql-test)\.[jt]sx?$/.test(file))
const violations = []
const customAlphabetAllowlist = new Set(['shared/ids.ts', 'shared/org-slugs.ts', 'server/auth.ts'])
for (const file of files) {
const source = readFileSync(file, 'utf8')
if (/import\s*\{[^}]*\bnanoid\b[^}]*\}\s*from\s*['"]nanoid['"]/.test(source)) {
violations.push(`${file}: imports the default nanoid generator`)
}
if (
/import\s*\{[^}]*\bcustomAlphabet\b[^}]*\}\s*from\s*['"]nanoid['"]/.test(source) &&
!customAlphabetAllowlist.has(file)
) {
violations.push(`${file}: imports customAlphabet outside the reviewed generator allowlist`)
}
}
if (violations.length > 0) {
throw new Error(`Uncontrolled ID generation:\n${violations.join('\n')}`)
}
+21
View File
@@ -0,0 +1,21 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execFileSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
describe('ID generation lint', () => {
it('rejects default nanoid imports in production sources', () => {
const dir = mkdtempSync(join(tmpdir(), 'zpan-id-lint-'))
try {
execFileSync('git', ['init', '-q'], { cwd: dir })
mkdirSync(join(dir, 'server'))
writeFileSync(join(dir, 'server/bad.ts'), "import { nanoid } from 'nanoid'\nexport const id = nanoid()\n")
execFileSync('git', ['add', 'server/bad.ts'], { cwd: dir })
const script = join(process.cwd(), 'scripts/lint-id-generation.mjs')
expect(() => execFileSync('node', [script], { cwd: dir, stdio: 'pipe' })).toThrow(/default nanoid generator/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+3 -3
View File
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { nanoid } from 'nanoid'
import { generateId } from '../shared/ids'
export const PREVIEW_ADMIN_EMAIL = 'admin@zpan.space'
export const CREDENTIAL_ACCOUNT_ISSUER = 'local:credential'
@@ -100,8 +100,8 @@ credential source. Do not commit or print it.`)
const sql = buildPreviewAdminSeedSql({
email: PREVIEW_ADMIN_EMAIL,
passwordHash: hashPassword(password),
userId: `preview-admin-${nanoid(12)}`,
accountId: `preview-admin-account-${nanoid(12)}`,
userId: generateId(),
accountId: generateId(),
now: Date.now(),
})
@@ -177,7 +177,7 @@ const HOURLY_SOURCES: readonly HourlySource[] = [
where: `registered_user.action = 'user_register'
AND registered_user.target_id IS NOT NULL
AND registered_user.user_id = registered_user.target_id
AND registered_user.id = 'event:user_register:' || registered_user.target_id
AND registered_user.event_key = 'event:user_register:' || registered_user.target_id
AND json_valid(registered_user.metadata) = 1
AND json_type(registered_user.metadata, '$.provider') = 'text'
AND length(json_extract(registered_user.metadata, '$.provider')) > 0`,
@@ -216,9 +216,7 @@ export function buildAdminStatsCounterRollupInsertSqlStatements(range: AdminStat
count, bytes, unique_count, metadata, updated_at
)
SELECT
CAST(bucket_start AS TEXT) || ':' || COALESCE(NULLIF(org_id, ''), 'global') || ':' ||
metric_key || ':' || COALESCE(NULLIF(dimension_key, ''), 'all') || ':' ||
COALESCE(NULLIF(hex(dimension_value), ''), 'all'),
lower(hex(json_array(bucket_start, org_id, metric_key, dimension_key, dimension_value))),
bucket_start, org_id, metric_key, dimension_key, dimension_value,
count, bytes, unique_count,
json_object('version', ${ROLLUP_VERSION}, 'scope', 'counters', 'quality', 'exact'),
@@ -83,7 +83,7 @@ describe('admin stats source integrity', () => {
expect(() => assertAdminStatsSourceIntegrity(integrity)).not.toThrow()
const rows = await db.all<{ events: number; issuedAt: number }>(sql`
SELECT
(SELECT COUNT(*) FROM audit_events WHERE id = 'event:download_issued:traffic-integrity-ok') AS events,
(SELECT COUNT(*) FROM audit_events WHERE event_key = 'event:download_issued:traffic-integrity-ok') AS events,
(SELECT issued_at FROM cloud_traffic_reports WHERE event_id = 'traffic-integrity-ok') AS issuedAt
`)
expect(rows).toEqual([{ events: 0, issuedAt: issuedAt.getTime() }])
@@ -130,7 +130,7 @@ describe('admin stats source integrity', () => {
await expect(reports.markIssued('traffic-does-not-exist', now)).rejects.toThrow('traffic_report_not_found')
const rows = await db.all<{ issuedAt: number | null; events: number }>(sql`
SELECT issued_at AS issuedAt,
(SELECT COUNT(*) FROM audit_events WHERE id LIKE 'event:download_issued:traffic-%') AS events
(SELECT COUNT(*) FROM audit_events WHERE event_key LIKE 'event:download_issued:traffic-%') AS events
FROM cloud_traffic_reports
WHERE event_id = 'traffic-activity-mismatch'
`)
@@ -5,7 +5,7 @@ import type { Database } from '../../platform/interface'
import { createCloudTrafficReportRepo, trafficLedgerExactFrom } from './cloud-traffic-report'
const OPENING_SOURCE_ID = 'v3-authoritative-sources'
const OPENING_EVENT_ID = `audit:statistics_source_initialized:${OPENING_SOURCE_ID}`
const OPENING_EVENT_KEY = `audit:statistics_source_initialized:${OPENING_SOURCE_ID}`
const OPENING_OPTION_KEY = 'stats_integrity_exact_from_v3'
export interface AdminStatsSourceIntegrity {
@@ -31,14 +31,14 @@ export async function ensureAdminStatsIntegrityOpening(db: Database, now = new D
const legacy = await db
.select({ createdAt: auditEvents.createdAt })
.from(auditEvents)
.where(eq(auditEvents.id, OPENING_EVENT_ID))
.where(eq(auditEvents.eventKey, OPENING_EVENT_KEY))
.limit(1)
const exactFrom = legacy[0]?.createdAt ?? new Date((Math.floor(now.getTime() / 1000) + 1) * 1000)
await db
.insert(systemOptions)
.values({ key: OPENING_OPTION_KEY, value: exactFrom.toISOString() })
.onConflictDoNothing({ target: systemOptions.key })
await db.delete(auditEvents).where(eq(auditEvents.id, OPENING_EVENT_ID))
await db.delete(auditEvents).where(eq(auditEvents.eventKey, OPENING_EVENT_KEY))
const rows = await db
.select({ value: systemOptions.value })
@@ -115,7 +115,7 @@ export async function inspectAdminStatsSourceIntegrity(
json_valid(metadata) = 0
OR target_id IS NULL
OR user_id <> target_id
OR id <> 'event:user_register:' || target_id
OR event_key <> 'event:user_register:' || target_id
OR COALESCE(json_type(metadata, '$.provider') = 'text', 0) = 0
OR COALESCE(length(json_extract(metadata, '$.provider')), 0) = 0
))
@@ -166,7 +166,7 @@ export async function inspectAdminStatsSourceIntegrity(
SELECT 1
FROM audit_events registration_event
WHERE registration_event.action = 'user_register'
AND registration_event.id = 'event:user_register:' || registered_user.id
AND registration_event.event_key = 'event:user_register:' || registered_user.id
AND registration_event.user_id = registered_user.id
AND registration_event.target_id = registered_user.id
AND registration_event.created_at = CAST(registered_user.created_at / 1000 AS INTEGER)
@@ -67,7 +67,7 @@ describe('admin hourly stats rollup', () => {
await db.run(sql`
UPDATE audit_events
SET created_at = ${atSec}, metadata = '{"provider":"credential"}'
WHERE id = ${`event:user_register:${userId}`}
WHERE event_key = ${`event:user_register:${userId}`}
`)
await db.run(sql`UPDATE session SET created_at = ${atMs}, updated_at = ${atMs} WHERE user_id = ${userId}`)
await db.run(sql`UPDATE organization SET created_at = ${atMs}, updated_at = ${atMs} WHERE id = ${orgId}`)
@@ -166,9 +166,14 @@ describe('admin hourly stats rollup', () => {
('rollup-share-created-limited', ${orgId}, ${userId}, 'user', 'share_create', 'share', 'rollup-share-limited', 'rollup-share-limited',
'{"kind":"direct"}', ${atSec}),
('rollup-job-finished-fact', ${orgId}, NULL, 'system', 'background_job_failed', 'background_job', 'rollup-job-finished', 'rollup-job-finished',
'{"jobType":"archive","outcome":"failed"}', ${atSec}),
('event:user_register:rollup-direct-user', '', 'rollup-direct-user', 'user', 'user_register', 'user',
'rollup-direct-user', 'rollup-direct-user', '{"provider":"unknown"}', ${atSec})
'{"jobType":"archive","outcome":"failed"}', ${atSec})
`)
await db.run(sql`
INSERT INTO audit_events
(id, event_key, org_id, user_id, actor_type, action, target_type, target_id, target_name, metadata, created_at)
VALUES
('RollupDirectRegistration', 'event:user_register:rollup-direct-user', '', 'rollup-direct-user', 'user',
'user_register', 'user', 'rollup-direct-user', 'rollup-direct-user', '{"provider":"unknown"}', ${atSec})
`)
await db.run(sql`
INSERT INTO object_upload_sessions
+3 -6
View File
@@ -1,3 +1,4 @@
import { generateId } from '@shared/ids'
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
import { organization } from '../../db/auth-schema'
import {
@@ -133,7 +134,7 @@ export async function rebuildAdminStatsHour(
const updatedAt = generatedAt
const rows = rollups.values().map((row) => ({
id: rollupId(bucketStart, row),
id: generateId(),
bucketStart,
orgId: row.orgId,
metricKey: row.metric,
@@ -198,7 +199,7 @@ export async function captureAdminStatsSnapshot(
await addSnapshotMetrics(db, rollups, observedAt)
rollups.add(M.statsRollupRun, '', 1, 0, { outcome: 'success' })
const rows = rollups.values().map((row) => ({
id: rollupId(bucketStart, row),
id: generateId(),
bucketStart,
orgId: row.orgId,
metricKey: row.metric,
@@ -897,10 +898,6 @@ function key(metric: AdminStatsMetric, orgId: string, dimensionKey: string, dime
return `${metric}\u0000${orgId}\u0000${dimensionKey}\u0000${dimensionValue}`
}
function rollupId(bucketStart: Date, row: RollupValue): string {
return `${bucketStart.getTime()}:${row.orgId || 'global'}:${row.metric}:${row.dimensionKey || 'all'}:${row.dimensionValue || 'all'}`
}
async function queryStage<T>(name: string, query: PromiseLike<T>): Promise<T> {
try {
return await query
+1 -1
View File
@@ -151,7 +151,7 @@ async function getLiveUserSummary(db: Database, now: Date): Promise<{ total: num
eq(auditEvents.action, 'user_register'),
sql`${auditEvents.targetId} IS NOT NULL`,
sql`${auditEvents.userId} = ${auditEvents.targetId}`,
sql`${auditEvents.id} = 'event:user_register:' || ${auditEvents.targetId}`,
sql`${auditEvents.eventKey} = 'event:user_register:' || ${auditEvents.targetId}`,
gte(auditEvents.createdAt, new Date(fromMs)),
lte(auditEvents.createdAt, new Date(nowMs)),
sql`json_valid(${auditEvents.metadata}) = 1`,
+2 -2
View File
@@ -1,6 +1,6 @@
import { generateId } from '@shared/ids'
import type { AnnouncementInput } from '@shared/schemas'
import { count, desc, eq, getTableColumns, ne, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { announcements } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { AnnouncementRecord, AnnouncementRepo } from '../../usecases/ports'
@@ -31,7 +31,7 @@ export function createAnnouncementRepo(db: Database): AnnouncementRepo {
async create(input, createdBy) {
const now = new Date()
const row: AnnouncementRow = {
id: nanoid(),
id: generateId(),
title: input.title,
body: input.body,
status: input.status,
+5 -5
View File
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { and, count, desc, eq, getTableColumns, gte, lte, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { organization, user } from '../../db/auth-schema'
import { auditEvents } from '../../db/schema'
import { assertAuditEvent } from '../../domain/audit-events'
@@ -9,7 +9,7 @@ import type { AuditActorType, AuditRepo, RecordAuditEventInput } from '../../use
export function auditEventValues(event: RecordAuditEventInput): typeof auditEvents.$inferInsert {
assertAuditEvent(event)
return {
id: nanoid(),
id: generateId(),
orgId: event.orgId,
userId: event.userId ?? null,
actorType: event.actorType ?? (event.userId ? 'user' : 'anonymous'),
@@ -52,7 +52,7 @@ export function idempotentSystemEventValues(input: {
targetName: input.targetName ?? targetId,
metadata: input.metadata,
}),
id: `event:${input.action}:${input.sourceId}`,
eventKey: `event:${input.action}:${input.sourceId}`,
createdAt: input.occurredAt,
}
}
@@ -96,10 +96,10 @@ export function createAuditRepo(db: Database): AuditRepo {
.insert(auditEvents)
.values({
...auditEventValues(event),
id: `event:${event.action}:${idempotencyKey}`,
eventKey: `event:${event.action}:${idempotencyKey}`,
createdAt: occurredAt,
})
.onConflictDoNothing({ target: auditEvents.id })
.onConflictDoNothing({ target: auditEvents.eventKey })
},
async list(orgId, opts) {
+3 -3
View File
@@ -1,6 +1,6 @@
import { generateId } from '@shared/ids'
import type { BackgroundJob, BackgroundJobStatus } from '@shared/types'
import { and, count, desc, eq, inArray, lt, or, type SQL, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { backgroundJobs } from '../../db/schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
@@ -91,7 +91,7 @@ export function createBackgroundJobRepo(db: Database): BackgroundJobRepo {
async create(input) {
const now = new Date()
const row: typeof backgroundJobs.$inferInsert = {
id: nanoid(),
id: generateId(),
orgId: input.orgId,
userId: input.userId,
type: input.type,
@@ -239,7 +239,7 @@ export function createBackgroundJobRepo(db: Database): BackgroundJobRepo {
const now = new Date()
const retry: typeof backgroundJobs.$inferInsert = {
id: nanoid(),
id: generateId(),
orgId: row.orgId,
userId: row.userId,
type: row.type,
+3 -3
View File
@@ -1,8 +1,8 @@
import { generateId } from '@shared/ids'
import { isPersonalOrgLike } from '@shared/org-slugs'
import type { CloudOrderQuotaChange } from '@shared/schemas'
import type { CloudStoreTarget } from '@shared/types'
import { and, eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { member, organization, user } from '../../db/auth-schema'
import { orgQuotaEntitlements, orgQuotas, webhookEvents } from '../../db/schema'
import { type AtomicQuery, executeRows, executeWriteTransaction } from '../../db/transaction'
@@ -205,7 +205,7 @@ function quotaEntitlementValue(
): typeof orgQuotaEntitlements.$inferInsert | null {
if (bytes === 0) return null
return {
id: nanoid(),
id: generateId(),
orgId: event.targetOrgId,
resourceType,
entitlementType: entitlementType(event),
@@ -272,7 +272,7 @@ async function beginWebhookEvent(
rawPayload: string,
payloadHash: string,
): Promise<{ id: string; duplicate: boolean }> {
const id = nanoid()
const id = generateId()
const inserted = await executeRows(
db
.insert(webhookEvents)
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { and, asc, eq, isNull, lte, ne, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { cloudTrafficReports } from '../../db/schema'
import { executeWriteTransaction } from '../../db/transaction'
import { currentTrafficPeriod } from '../../domain/quota'
@@ -64,7 +64,7 @@ export function createCloudTrafficReportRepo(db: Database): CloudTrafficReportRe
await executeWriteTransaction(db, [
ledgerOpeningInsert(db, input.now),
db.insert(cloudTrafficReports).values({
id: nanoid(),
id: generateId(),
orgId: input.orgId,
period: input.period,
source: input.source,
@@ -143,7 +143,7 @@ function ledgerOpeningInsert(db: Database, now: Date) {
return db
.insert(cloudTrafficReports)
.values({
id: TRAFFIC_LEDGER_OPENING_EVENT_ID,
id: generateId(),
orgId: '',
period: currentTrafficPeriod(now),
source: 'object_download',
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { and, eq, gt, isNull, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { downloaderBootstrapCredential, session } from '../../db/auth-schema'
import { downloaders } from '../../db/schema'
import { executeRows, executeWriteTransactionWithResults } from '../../db/transaction'
@@ -15,7 +15,7 @@ export function createDownloaderBootstrapCredentialRepo(
return {
async issue(input) {
await db.insert(downloaderBootstrapCredential).values({
id: nanoid(),
id: generateId(),
tokenHash: await tokens.hashDownloadToken(input.platform, input.token),
userId: input.userId,
deviceCode: input.deviceCode,
+83 -51
View File
@@ -1,6 +1,6 @@
import { generateId, generateToken } from '@shared/ids'
import { and, asc, eq, gt, isNotNull, isNull, like, or, sql } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { imageHostingConfigs, imageHostings } from '../../db/schema'
import { imageHostingConfigs, imageHostings, redirectTokenRegistry } from '../../db/schema'
import { type AtomicQuery, executeWriteTransaction, executeWriteTransactionWithResults } from '../../db/transaction'
import { mimeToExt } from '../../lib/mime-utils'
import type { Database } from '../../platform/interface'
@@ -10,6 +10,7 @@ import type {
ImageHostingRepo,
ImageResolution,
} from '../../usecases/ports'
import { withRedirectToken } from './redirect-token'
import { resourceChangeQuery } from './resource-change'
import {
imageActivationLedgerQuery,
@@ -21,8 +22,6 @@ import { imageAddedProjectionQueries, imageRemovedProjectionQueries } from './st
type ImageHostingRow = typeof imageHostings.$inferSelect
const MAX_COLLISION_RETRIES = 5
const imageTokenSuffix = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10)
function toRecord(row: ImageHostingRow): ImageHostingRecord {
return row as unknown as ImageHostingRecord
}
@@ -66,8 +65,7 @@ export function createImageHostingRepo(db: Database): ImageHostingRepo {
if (conflict.length === 0) return candidate
}
// Exhausted retries — use nanoid suffix as fallback
return `${prefix}${stem}-${nanoid(4)}${ext}`
return `${prefix}${stem}-${generateToken(5)}${ext}`
}
return {
@@ -136,49 +134,72 @@ export function createImageHostingRepo(db: Database): ImageHostingRepo {
},
async create(input: CreateImageHostingInput) {
const id = nanoid(12)
const token = `ih${imageTokenSuffix()}`
const id = generateId(13)
const ext = mimeToExt(input.mime)
const storageKey = `ih/${input.orgId}/${id}.${ext}`
const now = new Date()
const resolvedPath = await resolveUniquePath(input.orgId, input.path)
const row: ImageHostingRow = {
id,
orgId: input.orgId,
token,
path: resolvedPath,
storageId: input.storageId,
storageKey,
size: input.size,
mime: input.mime,
width: null,
height: null,
status: input.status,
purgedAt: null,
accessCount: 0,
lastAccessedAt: null,
createdAt: now,
}
return withRedirectToken(
12,
async (token) => {
const row: ImageHostingRow = {
id,
orgId: input.orgId,
token,
path: resolvedPath,
storageId: input.storageId,
storageKey,
size: input.size,
mime: input.mime,
width: null,
height: null,
status: input.status,
purgedAt: null,
accessCount: 0,
lastAccessedAt: null,
createdAt: now,
}
await executeWriteTransaction(db, [
db.insert(imageHostings).values(row),
...(row.status === 'active'
? [
resourceChangeQuery(db, {
scopeType: 'organization',
scopeId: row.orgId,
resourceType: 'image_hosting',
resourceId: row.id,
changeType: 'upsert',
action: 'created',
occurredAt: now,
}),
]
: []),
])
return toRecord(row)
await executeWriteTransaction(db, [
db.insert(imageHostings).values(row),
db.insert(redirectTokenRegistry).values({
token: row.token,
kind: 'image_hosting',
resourceId: row.id,
}),
...(row.status === 'active'
? [
resourceChangeQuery(db, {
scopeType: 'organization',
scopeId: row.orgId,
resourceType: 'image_hosting',
resourceId: row.id,
changeType: 'upsert',
action: 'created',
occurredAt: now,
}),
]
: []),
])
return toRecord(row)
},
async (token) => {
const existingImage = await db
.select({ id: imageHostings.id })
.from(imageHostings)
.where(eq(imageHostings.token, token))
.limit(1)
if (existingImage.length > 0) return true
const reservation = await db
.select({ token: redirectTokenRegistry.token })
.from(redirectTokenRegistry)
.where(eq(redirectTokenRegistry.token, token))
.limit(1)
return reservation.length > 0
},
)
},
async get(id, orgId) {
@@ -283,16 +304,27 @@ export function createImageHostingRepo(db: Database): ImageHostingRepo {
const row = existing[0]
if (!row) return
if (row.status === 'draft') {
await db
.delete(imageHostings)
.where(
and(
eq(imageHostings.id, id),
eq(imageHostings.orgId, orgId),
eq(imageHostings.status, 'draft'),
isNull(imageHostings.purgedAt),
await executeWriteTransaction(db, [
db
.delete(imageHostings)
.where(
and(
eq(imageHostings.id, id),
eq(imageHostings.orgId, orgId),
eq(imageHostings.status, 'draft'),
isNull(imageHostings.purgedAt),
),
),
)
db
.delete(redirectTokenRegistry)
.where(
and(
eq(redirectTokenRegistry.resourceId, id),
eq(redirectTokenRegistry.kind, 'image_hosting'),
sql`NOT EXISTS (SELECT 1 FROM image_hostings WHERE image_hostings.id = ${id})`,
),
),
])
return
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { systemOptions } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { InstanceRepo } from '../../usecases/ports'
@@ -16,7 +16,7 @@ export function createInstanceRepo(db: Database): InstanceRepo {
.limit(1)
if (rows[0]?.value) return rows[0].value
const id = nanoid(21)
const id = generateId()
await db
.insert(systemOptions)
.values({ key: INSTANCE_ID_KEY, value: id })
@@ -22,11 +22,11 @@ describe('generateInviteCodes', () => {
expect(uniqueCodes.size).toBe(10)
})
it('each code has an 8-character uppercase alphanumeric code field', async () => {
it('each code has an 8-character Base62 code field', async () => {
const { db } = await createTestApp()
const codes = await createInviteRepo(db).generate('admin-1', 3)
for (const code of codes) {
expect(code.code).toMatch(/^[0-9A-Z]{8}$/)
expect(code.code).toMatch(/^[A-Za-z0-9]{8}$/)
}
})
+3 -5
View File
@@ -1,18 +1,16 @@
import { generateId, generateToken } from '@shared/ids'
import { and, count, desc, eq, getTableColumns, gt, isNull, or, sql } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { inviteCodes } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { InviteCodeRecord, InviteRepo } from '../../usecases/ports'
const generateCode = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8)
export function createInviteRepo(db: Database): InviteRepo {
return {
async generate(adminUserId, quantity, expiresAt) {
const now = new Date()
const rows: InviteCodeRecord[] = Array.from({ length: quantity }, () => ({
id: nanoid(),
code: generateCode(),
id: generateId(),
code: generateToken(8),
createdBy: adminUserId,
usedBy: null,
usedAt: null,
+2 -2
View File
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { licenseBindings } from '../../db/schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
@@ -54,7 +54,7 @@ async function createLicenseBinding(db: Database, input: CreateLicenseBindingInp
})
.where(eq(licenseBindings.status, 'active')),
db.insert(licenseBindings).values({
id: nanoid(),
id: generateId(),
cloudBindingId: input.cloudBindingId,
cloudStoreId: input.cloudStoreId ?? null,
instanceId: input.instanceId,
+5 -5
View File
@@ -1,4 +1,5 @@
import { DirType, ObjectStatus } from '@shared/constants'
import { generateId, generateToken } from '@shared/ids'
import type { SQL } from 'drizzle-orm'
import {
aliasedTable,
@@ -18,7 +19,6 @@ import {
or,
sql,
} from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { matters } from '../../db/schema'
import { type AtomicQuery, executeWriteTransaction, executeWriteTransactionWithResults } from '../../db/transaction'
import { suggestRenamed } from '../../domain/matter-name-conflict'
@@ -297,9 +297,9 @@ export function createMatterRepo(db: Database): MatterRepo {
const finalName = plan.finalName
const row: MatterRow = {
id: nanoid(),
id: generateId(),
orgId: input.orgId,
alias: nanoid(10),
alias: generateToken(11),
name: finalName,
type: input.type,
size: input.size ?? 0,
@@ -508,9 +508,9 @@ export function createMatterRepo(db: Database): MatterRepo {
)
const row: MatterRow = {
id: nanoid(),
id: generateId(),
orgId: source.orgId,
alias: nanoid(10),
alias: generateToken(11),
name: finalName,
type: source.type,
size: source.size,
+2 -2
View File
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { and, count, desc, eq, isNull, lt, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { notifications } from '../../db/schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
@@ -16,7 +16,7 @@ export function createNotificationRepo(db: Database): NotificationRepo {
return {
async create(input) {
const row: NotificationRow = {
id: nanoid(),
id: generateId(),
userId: input.userId,
type: input.type,
title: input.title,
@@ -1,3 +1,4 @@
import { generateId } from '@shared/ids'
import { and, eq } from 'drizzle-orm'
import { oauthClient, oauthClientRegistration, oauthClientResource, oauthResource } from '../../db/auth-schema'
import { executeWriteTransaction } from '../../db/transaction'
@@ -50,7 +51,7 @@ export async function replaceManagedOAuthClient(
): Promise<void> {
const resourceQueries = resourceIds.map((resourceId) =>
db.insert(oauthClientResource).values({
id: `${clientId}::${resourceId}`,
id: generateId(),
clientId,
resourceId,
}),
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { objectUploadSessions } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { ObjectUploadSessionRecord, ObjectUploadSessionRepo } from '../../usecases/ports'
@@ -29,7 +29,7 @@ export function createObjectUploadSessionRepo(db: Database): ObjectUploadSession
async create(input) {
const now = new Date()
const row: typeof objectUploadSessions.$inferInsert = {
id: nanoid(),
id: generateId(),
orgId: input.orgId,
objectId: input.objectId,
storageId: input.storageId,
@@ -0,0 +1,54 @@
import { env } from 'cloudflare:workers'
import { beforeEach, describe, expect, it } from 'vitest'
import { withRedirectToken } from './redirect-token'
const RESOURCE_TABLE = 'test_redirect_token_resource'
const REGISTRY_TABLE = 'test_redirect_token_registry'
describe('[CF] redirect token collision recovery', () => {
beforeEach(async () => {
await env.DB.exec(`
DROP TABLE IF EXISTS ${RESOURCE_TABLE};
DROP TABLE IF EXISTS ${REGISTRY_TABLE};
CREATE TABLE ${RESOURCE_TABLE} (id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE);
CREATE TABLE ${REGISTRY_TABLE} (token TEXT PRIMARY KEY, resource_id TEXT NOT NULL UNIQUE);
INSERT INTO ${REGISTRY_TABLE} VALUES ('TakenToken1', 'ExistingImage1');
`)
})
it('retries a real D1 uniqueness conflict without inspecting driver error text', async () => {
const tokens = ['TakenToken1', 'FreshToken2']
let forcedRace = true
const isTaken = async (token: string) => {
if (token === 'TakenToken1' && forcedRace) {
forcedRace = false
return false
}
return (
(await env.DB.prepare(
`SELECT 1 FROM ${RESOURCE_TABLE} WHERE token = ?1 UNION ALL SELECT 1 FROM ${REGISTRY_TABLE} WHERE token = ?1 LIMIT 1`,
)
.bind(token)
.first()) !== null
)
}
const result = await withRedirectToken(
11,
async (token) => {
await env.DB.batch([
env.DB.prepare(`INSERT INTO ${RESOURCE_TABLE} VALUES (?1, ?2)`).bind(`Resource${token}`, token),
env.DB.prepare(`INSERT INTO ${REGISTRY_TABLE} VALUES (?1, ?2)`).bind(token, `Resource${token}`),
])
return token
},
isTaken,
() => tokens.shift()!,
)
expect(result).toBe('FreshToken2')
expect(await env.DB.prepare(`SELECT token FROM ${RESOURCE_TABLE}`).all()).toMatchObject({
results: [{ token: 'FreshToken2' }],
})
})
})
@@ -0,0 +1,81 @@
import Database from 'better-sqlite3'
import { describe, expect, it, vi } from 'vitest'
import { withRedirectToken } from './redirect-token'
describe('withRedirectToken', () => {
it('skips candidates already reserved in the token namespace', async () => {
const tokens = ['FirstToken1', 'SecondToken2', 'ThirdToken3']
const taken = new Set(['FirstToken1', 'SecondToken2'])
const operation = vi.fn(async (token: string) => token)
await expect(
withRedirectToken(
11,
operation,
async (token) => taken.has(token),
() => tokens.shift()!,
),
).resolves.toBe('ThirdToken3')
expect(operation).toHaveBeenCalledTimes(1)
})
it('fails after the bounded collision budget is exhausted', async () => {
const operation = vi.fn(async () => {
throw new Error('driver-specific unique failure')
})
await expect(
withRedirectToken(
11,
operation,
async () => true,
() => 'SameToken11',
),
).rejects.toThrow('redirect_token_collision_budget_exhausted')
expect(operation).not.toHaveBeenCalled()
})
it('does not hide unrelated database failures', async () => {
const failure = new Error('database is locked')
const operation = vi.fn(async () => {
throw failure
})
await expect(withRedirectToken(11, operation, async () => false)).rejects.toBe(failure)
expect(operation).toHaveBeenCalledTimes(1)
})
it('retries a real unique conflict after the failed resource transaction rolls back', async () => {
const db = new Database(':memory:')
db.exec(`
CREATE TABLE resources (id TEXT PRIMARY KEY, token TEXT NOT NULL);
CREATE TABLE redirect_token_registry (token TEXT PRIMARY KEY, kind TEXT NOT NULL, resource_id TEXT NOT NULL);
INSERT INTO redirect_token_registry VALUES ('TakenToken1', 'image_hosting', 'ExistingImage1');
`)
const tokens = ['TakenToken1', 'FreshToken2']
let forcedRace = true
const result = await withRedirectToken(
11,
async (token) => {
db.transaction(() => {
db.prepare('INSERT INTO resources VALUES (?, ?)').run(`Resource${token}`, token)
db.prepare("INSERT INTO redirect_token_registry VALUES (?, 'direct_share', ?)").run(token, `Resource${token}`)
})()
return token
},
async (token) => {
if (token === 'TakenToken1' && forcedRace) {
forcedRace = false
return false
}
return db.prepare('SELECT 1 FROM redirect_token_registry WHERE token = ? LIMIT 1').get(token) !== undefined
},
() => tokens.shift()!,
)
expect(result).toBe('FreshToken2')
expect(db.prepare('SELECT token FROM resources').all()).toEqual([{ token: 'FreshToken2' }])
db.close()
})
})
+24
View File
@@ -0,0 +1,24 @@
import { generateToken } from '@shared/ids'
const MAX_TOKEN_COLLISION_RETRIES = 5
export async function withRedirectToken<T>(
length: number,
operation: (token: string) => Promise<T>,
isTaken: (token: string) => Promise<boolean>,
tokenGenerator: (length: number) => string = generateToken,
): Promise<T> {
for (let attempt = 0; attempt < MAX_TOKEN_COLLISION_RETRIES; attempt += 1) {
const token = tokenGenerator(length)
if (await isTaken(token)) continue
try {
return await operation(token)
} catch (error) {
// Database drivers do not expose a portable SQLite constraint code. Query
// the shared namespace after a failed transaction and retry only when the
// candidate is demonstrably occupied; unrelated failures retain identity.
if (!(await isTaken(token))) throw error
}
}
throw new Error('redirect_token_collision_budget_exhausted')
}
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { asc, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { remoteDownloadUsageReports } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type {
@@ -39,7 +39,7 @@ export function createRemoteDownloadUsageRepo(db: Database): RemoteDownloadUsage
async insert(input: InsertRemoteDownloadUsageReportInput) {
await db.insert(remoteDownloadUsageReports).values({
id: nanoid(),
id: generateId(),
orgId: input.orgId,
downloaderId: input.downloaderId,
taskId: input.taskId,
@@ -3,7 +3,7 @@ import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { DirType } from '../../../shared/constants'
import type { CreateShareInput } from '../../../shared/schemas/share'
import { matters } from '../../db/schema'
import { matters, redirectTokenRegistry } from '../../db/schema'
import { isAccessibleByUser } from '../../domain/share'
import { verifyPassword as verifyPasswordHash } from '../../lib/password'
import type { Database } from '../../platform/interface'
@@ -80,7 +80,8 @@ describe('createShare', () => {
})
expect(share.id).toBeTruthy()
expect(share.token).toHaveLength(10)
expect(share.token).toHaveLength(11)
expect(share.token).toMatch(/^[A-Za-z0-9]+$/)
expect(share.kind).toBe('landing')
expect(share.status).toBe('active')
expect(share.passwordHash).toBeTruthy()
@@ -101,6 +102,9 @@ describe('createShare', () => {
expect(share.kind).toBe('direct')
expect(share.passwordHash).toBeNull()
await expect(
db.select().from(redirectTokenRegistry).where(eq(redirectTokenRegistry.token, share.token)),
).resolves.toEqual([{ token: share.token, kind: 'direct_share', resourceId: share.id }])
})
it('throws DIRECT_NO_PASSWORD when direct share has a password', async () => {
+68 -43
View File
@@ -1,9 +1,9 @@
import { DirType } from '@shared/constants'
import { generateId } from '@shared/ids'
import type { CreateShareInput } from '@shared/schemas/share'
import { and, count, desc, eq, isNotNull, isNull, like, lt, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { user } from '../../db/auth-schema'
import { matters, shareRecipients, shares } from '../../db/schema'
import { matters, redirectTokenRegistry, shareRecipients, shares } from '../../db/schema'
import { type AtomicQuery, executeWriteTransaction } from '../../db/transaction'
import { hashPassword } from '../../lib/password'
import type { Database } from '../../platform/interface'
@@ -16,6 +16,7 @@ import {
type ShareResolution,
} from '../../usecases/ports'
import { createQuotaRepo } from './quota'
import { withRedirectToken } from './redirect-token'
import { resourceChangeQuery } from './resource-change'
function buildPath(parent: string, name: string): string {
@@ -45,49 +46,73 @@ export function createShareRepo(db: Database): ShareRepo {
if (input.kind === 'direct' && matter.dirtype !== DirType.FILE) throw new CreateShareError('DIRECT_NO_FOLDER')
const now = new Date()
const token = input.kind === 'direct' ? `ds_${nanoid(10)}` : nanoid(10)
const share: ShareRecord = {
id: nanoid(),
token,
kind: input.kind,
matterId: input.matterId,
orgId: input.orgId,
creatorId: input.creatorId,
passwordHash: input.password ? hashPassword(input.password) : null,
expiresAt: input.expiresAt ?? null,
downloadLimit: input.downloadLimit ?? null,
views: 0,
downloads: 0,
status: 'active',
private: input.private ?? false,
createdAt: now,
}
const id = generateId()
return withRedirectToken(
11,
async (token) => {
const share: ShareRecord = {
id,
token,
kind: input.kind,
matterId: input.matterId,
orgId: input.orgId,
creatorId: input.creatorId,
passwordHash: input.password ? hashPassword(input.password) : null,
expiresAt: input.expiresAt ?? null,
downloadLimit: input.downloadLimit ?? null,
views: 0,
downloads: 0,
status: 'active',
private: input.private ?? false,
createdAt: now,
}
const queries: AtomicQuery[] = [
db.insert(shares).values(share),
resourceChangeQuery(db, {
scopeType: 'user',
scopeId: share.creatorId,
resourceType: 'share',
resourceId: share.id,
changeType: 'upsert',
action: 'created',
occurredAt: now,
}),
]
if (input.recipients && input.recipients.length > 0) {
const recipientRows = input.recipients.map((r) => ({
id: nanoid(),
shareId: share.id,
recipientUserId: r.recipientUserId ?? null,
recipientEmail: r.recipientEmail ?? null,
createdAt: now,
}))
queries.push(db.insert(shareRecipients).values(recipientRows))
}
const queries: AtomicQuery[] = [
db.insert(shares).values(share),
...(share.kind === 'direct'
? [
db.insert(redirectTokenRegistry).values({
token: share.token,
kind: 'direct_share',
resourceId: share.id,
}),
]
: []),
resourceChangeQuery(db, {
scopeType: 'user',
scopeId: share.creatorId,
resourceType: 'share',
resourceId: share.id,
changeType: 'upsert',
action: 'created',
occurredAt: now,
}),
]
if (input.recipients && input.recipients.length > 0) {
const recipientRows = input.recipients.map((r) => ({
id: generateId(),
shareId: share.id,
recipientUserId: r.recipientUserId ?? null,
recipientEmail: r.recipientEmail ?? null,
createdAt: now,
}))
queries.push(db.insert(shareRecipients).values(recipientRows))
}
await executeWriteTransaction(db, queries)
return share
await executeWriteTransaction(db, queries)
return share
},
async (token) => {
const existingShare = await db.select({ id: shares.id }).from(shares).where(eq(shares.token, token)).limit(1)
if (existingShare.length > 0 || input.kind !== 'direct') return existingShare.length > 0
const reservation = await db
.select({ token: redirectTokenRegistry.token })
.from(redirectTokenRegistry)
.where(eq(redirectTokenRegistry.token, token))
.limit(1)
return reservation.length > 0
},
)
},
async resolveByToken(token: string): Promise<ShareResolution> {
+4 -4
View File
@@ -1,7 +1,7 @@
import { DEFAULT_SITE_NAME } from '@shared/constants'
import { generateId, generateToken } from '@shared/ids'
import type { SiteInvitation } from '@shared/types'
import { and, count, desc, eq, gt, isNull, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import * as authSchema from '../../db/auth-schema'
import { siteInvitations, systemOptions } from '../../db/schema'
import type { Database } from '../../platform/interface'
@@ -108,9 +108,9 @@ async function createSiteInvitation(db: Database, adminUserId: string, rawEmail:
if (pendingInvite) throw new Error('A pending invitation already exists for this email')
const row: typeof siteInvitations.$inferInsert = {
id: nanoid(),
id: generateId(),
email,
token: nanoid(32),
token: generateToken(33),
invitedBy: adminUserId,
acceptedBy: null,
acceptedAt: null,
@@ -148,7 +148,7 @@ async function resendSiteInvitation(
if (row.revokedAt) return 'already_revoked'
const now = new Date()
const nextToken = nanoid(32)
const nextToken = generateToken(33)
await db
.update(siteInvitations)
.set({
+10 -10
View File
@@ -1,6 +1,6 @@
import { DirType } from '@shared/constants'
import { generateId, generateToken } from '@shared/ids'
import { and, asc, eq, isNull, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { imageHostings, matters, storageUsageLedger } from '../../db/schema'
import { type AtomicQuery, executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
@@ -65,7 +65,7 @@ export function storageUsageOpeningBalanceQuery(
return db
.insert(storageUsageLedger)
.values({
id: eventKey,
id: generateId(),
eventKey,
orgId,
storageId,
@@ -95,11 +95,11 @@ export function storageUsageOpeningBalanceQuery(
}
export function storageUsageMutationQuery(db: Database, mutation: StorageUsageLedgerMutation): AtomicQuery {
const eventKey = mutation.eventKey ?? `mutation:${nanoid()}`
const eventKey = mutation.eventKey ?? `mutation:${generateToken(22)}`
return db
.insert(storageUsageLedger)
.values({
id: nanoid(),
id: generateId(),
eventKey,
orgId: mutation.orgId,
storageId: mutation.storageId,
@@ -127,7 +127,7 @@ export function matterActivationLedgerQuery(
return conditionalMutationQuery(
db,
sql`SELECT
${nanoid()}, ${eventKey}, ${matters.orgId}, ${matters.storageId}, 'matter', ${matters.id},
${generateId()}, ${eventKey}, ${matters.orgId}, ${matters.storageId}, 'matter', ${matters.id},
${matters.size}, 'matter_activated', ${occurredAt.getTime()}, ${occurredAt.getTime()}
FROM ${matters}
WHERE ${matters.id} = ${matterId}
@@ -149,7 +149,7 @@ export function matterResizeLedgerQuery(
return conditionalMutationQuery(
db,
sql`SELECT
${nanoid()}, ${`matter:${matterId}:resized:${nanoid()}`}, ${matters.orgId}, ${matters.storageId}, 'matter',
${generateId()}, ${`matter:${matterId}:resized:${generateToken(22)}`}, ${matters.orgId}, ${matters.storageId}, 'matter',
${matters.id}, ${nextSize} - COALESCE(${matters.size}, 0), 'matter_resized',
${occurredAt.getTime()}, ${occurredAt.getTime()}
FROM ${matters}
@@ -167,7 +167,7 @@ export function matterPurgeLedgerQuery(db: Database, orgId: string, matterId: st
return conditionalMutationQuery(
db,
sql`SELECT
${nanoid()}, ${eventKey}, ${matters.orgId}, ${matters.storageId}, 'matter', ${matters.id},
${generateId()}, ${eventKey}, ${matters.orgId}, ${matters.storageId}, 'matter', ${matters.id},
-COALESCE(${matters.size}, 0), 'matter_purged', ${occurredAt.getTime()}, ${occurredAt.getTime()}
FROM ${matters}
WHERE ${matters.id} = ${matterId}
@@ -189,7 +189,7 @@ export function imageActivationLedgerQuery(
return conditionalMutationQuery(
db,
sql`SELECT
${nanoid()}, ${eventKey}, ${imageHostings.orgId}, ${imageHostings.storageId}, 'image_hosting',
${generateId()}, ${eventKey}, ${imageHostings.orgId}, ${imageHostings.storageId}, 'image_hosting',
${imageHostings.id}, ${imageHostings.size}, 'image_activated', ${occurredAt.getTime()}, ${occurredAt.getTime()}
FROM ${imageHostings}
WHERE ${imageHostings.id} = ${imageId}
@@ -205,7 +205,7 @@ export function imagePurgeLedgerQuery(db: Database, orgId: string, imageId: stri
return conditionalMutationQuery(
db,
sql`SELECT
${nanoid()}, ${eventKey}, ${imageHostings.orgId}, ${imageHostings.storageId}, 'image_hosting',
${generateId()}, ${eventKey}, ${imageHostings.orgId}, ${imageHostings.storageId}, 'image_hosting',
${imageHostings.id}, -${imageHostings.size}, 'image_purged', ${occurredAt.getTime()}, ${occurredAt.getTime()}
FROM ${imageHostings}
WHERE ${imageHostings.id} = ${imageId}
@@ -278,7 +278,7 @@ export async function ensureStorageUsageIntegrityOpeningBalances(db: Database, o
return db
.insert(storageUsageLedger)
.values({
id: eventKey,
id: generateId(),
eventKey,
orgId,
storageId,
+2 -2
View File
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { and, asc, count, eq, isNull, lt, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { matters, storages } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { CachePolicy, CacheService, StorageRecord, StorageRepo } from '../../usecases/ports'
@@ -70,7 +70,7 @@ export function createStorageRepo(db: Database, cache?: CacheService): StorageRe
async create(input) {
const now = new Date()
const row: StorageRow = {
id: nanoid(),
id: generateId(),
provider: input.provider ?? '',
bucket: input.bucket,
endpoint: input.endpoint,
+4 -5
View File
@@ -1,20 +1,19 @@
import { generateId, generateToken } from '@shared/ids'
import { and, desc, eq, gt, isNull, or } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { invitation, member, organization } from '../../db/auth-schema'
import { teamInviteLinks } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { TeamInviteLinkRecord, TeamInviteRepo } from '../../usecases/ports'
const generateToken = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 32)
const DEFAULT_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export function createTeamInviteRepo(db: Database): TeamInviteRepo {
return {
async createInviteLink(organizationId, inviterId, role, expiresIn) {
const token = generateToken()
const token = generateToken(32)
const now = new Date()
const row: TeamInviteLinkRecord = {
id: nanoid(),
id: generateId(),
token,
organizationId,
role,
@@ -68,7 +67,7 @@ export function createTeamInviteRepo(db: Database): TeamInviteRepo {
if (existing[0]) return 'already_member'
await db.insert(member).values({
id: nanoid(),
id: generateId(),
organizationId: link.organizationId,
userId,
role: link.role,
+3 -3
View File
@@ -1,6 +1,6 @@
import { generateId, generateToken } from '@shared/ids'
import { isPersonalOrgLike } from '@shared/org-slugs'
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { member, organization, user } from '../../db/auth-schema'
import { orgQuotaEntitlements } from '../../db/schema'
import type { Database } from '../../platform/interface'
@@ -142,12 +142,12 @@ async function grantOrgEntitlement(
if ('error' in org) return org
const now = new Date()
const entitlement = {
id: nanoid(),
id: generateId(),
orgId: input.orgId,
resourceType: input.resourceType,
entitlementType: 'grant',
source: 'admin_grant',
sourceId: `admin_grant:${nanoid()}`,
sourceId: `admin_grant:${generateToken(22)}`,
bytes: input.bytes,
startsAt: now,
expiresAt: input.expiresAt ?? null,
+4 -4
View File
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { and, eq, inArray, or, type SQL, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { webdavDeadProperties, webdavLocks } from '../../db/schema'
import { type AtomicQuery, executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
@@ -53,7 +53,7 @@ export function createWebDavStateRepo(db: Database): WebDavStateRepo {
db
.insert(webdavDeadProperties)
.values({
id: nanoid(),
id: generateId(),
orgId,
resourcePath,
namespace: property.namespace,
@@ -89,7 +89,7 @@ export function createWebDavStateRepo(db: Database): WebDavStateRepo {
db
.insert(webdavDeadProperties)
.values({
id: nanoid(),
id: generateId(),
orgId,
resourcePath: targetPath,
namespace: row.namespace,
@@ -280,7 +280,7 @@ function newLock(input: {
}): DavLock {
const now = new Date()
return {
id: nanoid(),
id: generateId(),
token: `opaquelocktoken:${crypto.randomUUID()}`,
orgId: input.orgId,
resourcePath: input.resourcePath,
@@ -1,5 +1,5 @@
import { generateId } from '@shared/ids'
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { x402CapacityPurchaseIntents } from '../../db/schema'
import { executeWriteTransactionWithResults } from '../../db/transaction'
import type { Database } from '../../platform/interface'
@@ -30,7 +30,7 @@ export function createX402CapacityPurchaseRepo(db: Database): X402CapacityPurcha
async create(input) {
const now = new Date()
const nowMs = now.getTime()
const id = nanoid()
const id = generateId()
const abandonedBefore = new Date(nowMs - PENDING_INTENT_TTL_MS)
const creationWindowStart = new Date(nowMs - INTENT_CREATION_WINDOW_MS)
const retentionBefore = new Date(nowMs - ABANDONED_INTENT_RETENTION_MS)
+23 -2
View File
@@ -81,6 +81,27 @@ describe('registration gate — first user always allowed', () => {
expect(res.status).toBe(200)
})
it('keeps Better Auth and ZPan-created authentication records on the Base62 contract', async () => {
const ctx = await createTestApp()
const res = await signUp(ctx, 'base62-contract@example.com')
const body = (await res.json()) as { user: { id: string } }
const rows = await ctx.db.all<{ id: string }>(sql`
SELECT id FROM user
UNION ALL SELECT id FROM account
UNION ALL SELECT id FROM session
UNION ALL SELECT id FROM organization
UNION ALL SELECT id FROM member
UNION ALL SELECT id FROM org_quotas
UNION ALL SELECT id FROM org_quota_entitlements
UNION ALL SELECT id FROM audit_events
`)
expect(res.status).toBe(200)
expect(body.user.id).toMatch(/^[A-Za-z0-9]+$/)
expect(rows.length).toBeGreaterThanOrEqual(8)
expect(rows.every(({ id }) => /^[A-Za-z0-9]+$/.test(id))).toBe(true)
})
it('first user can register when auth_signup_mode is invite_only without a code', async () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
@@ -875,7 +896,7 @@ describe('OAuth consent guards', () => {
expect(body).toMatchObject({
client_id: expect.any(String),
client_secret: expect.any(String),
registration_access_token: expect.stringMatching(/^zpr_/),
registration_access_token: expect.stringMatching(/^[A-Za-z0-9]{43}$/),
registration_client_uri: expect.stringMatching(/^http:\/\/localhost:3000\/api\/auth\/oauth2\/register\//),
token_endpoint_auth_method: 'client_secret_basic',
authorization_details_types: [WORKSPACE_AUTHORIZATION_DETAIL_TYPE],
@@ -1564,7 +1585,7 @@ describe('OAuth consent guards', () => {
const pushedBody = (await pushed.json()) as { request_uri: string; expires_in: number }
expect(pushed.status, JSON.stringify(pushedBody)).toBe(201)
expect(pushedBody).toMatchObject({
request_uri: expect.stringMatching(/^urn:ietf:params:oauth:request_uri:/),
request_uri: expect.stringMatching(/^urn:ietf:params:oauth:request_uri:[A-Za-z0-9]{33}$/),
expires_in: 90,
})
+6 -5
View File
@@ -18,7 +18,7 @@ import {
import { genericOAuth } from 'better-auth/plugins/generic-oauth'
import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
import { count, eq, like } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { customAlphabet } from 'nanoid'
import {
API_KEY_TEMPLATES,
ApiKeyTemplate,
@@ -31,6 +31,7 @@ import {
WEBDAV_RATE_LIMITER_BINDING,
} from '../shared/api-key-templates'
import { DEFAULT_ORG_QUOTA, DEFAULT_ORG_TRAFFIC_QUOTA, SignupMode } from '../shared/constants'
import { generateId } from '../shared/ids'
import { JWT_BEARER_GRANT_TYPE, OAUTH_SCOPES, TOKEN_EXCHANGE_GRANT_TYPE } from '../shared/oauth'
import {
BUILTIN_PROVIDER_IDS,
@@ -1021,7 +1022,7 @@ async function createPersonalOrg(
db: Database,
user: { id: string; name: string; username?: string | null },
): Promise<string> {
const orgId = nanoid()
const orgId = generateId()
const now = new Date()
const displayName = user.name || user.username
const orgName = displayName ? `${displayName}'s Space` : 'Personal Space'
@@ -1037,7 +1038,7 @@ async function createPersonalOrg(
createdAt: now,
}),
db.insert(authSchema.member).values({
id: nanoid(),
id: generateId(),
organizationId: orgId,
userId: user.id,
role: 'owner',
@@ -1067,7 +1068,7 @@ async function findPersonalOrgFromExistingSession(db: Database, userId: string):
async function createOrgQuotaValues(_db: Database, orgId: string, now: Date): Promise<typeof orgQuotas.$inferInsert> {
return {
id: nanoid(),
id: generateId(),
orgId,
quota: 0,
used: 0,
@@ -1125,7 +1126,7 @@ function freePlanEntitlementValue(
settingKey: string,
): typeof orgQuotaEntitlements.$inferInsert {
return {
id: nanoid(),
id: generateId(),
orgId,
resourceType,
entitlementType: 'plan',
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { generateToken } from '../../shared/ids'
import {
JWT_BEARER_GRANT_TYPE,
OAUTH_SCOPES,
@@ -111,7 +112,7 @@ export function addOAuthClientRegistrationManagementOpenApi(document: { paths: R
registrationSchema.properties = {
...properties,
registration_client_uri: { type: 'string', format: 'uri' },
registration_access_token: { type: 'string' },
registration_access_token: { type: 'string', pattern: '^[A-Za-z0-9]{43}$' },
}
registrationSchema.required = [
...new Set([
@@ -128,7 +129,7 @@ export function addOAuthClientRegistrationManagementOpenApi(document: { paths: R
properties: {
client_id: { type: 'string' },
registration_client_uri: { type: 'string', format: 'uri' },
registration_access_token: { type: 'string' },
registration_access_token: { type: 'string', pattern: '^[A-Za-z0-9]{43}$' },
scope: { type: 'string' },
},
required: ['client_id', 'registration_client_uri', 'registration_access_token'],
@@ -441,8 +442,7 @@ function mergedHeaders(...inputs: Array<HeadersInit | undefined>): Headers {
}
function registrationToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return `zpr_${base64Url(bytes)}`
return generateToken(43)
}
function configurationUrl(clientId: string, baseUrl: string): URL {
+2 -2
View File
@@ -2,8 +2,8 @@ import { getOAuthProviderApi, type oauthProvider } from '@better-auth/oauth-prov
import { parseWorkspaceAuthorizationDetails } from '@shared/schemas'
import { APIError, type BetterAuthPlugin } from 'better-auth'
import { createAuthEndpoint } from 'better-auth/api'
import { nanoid } from 'nanoid'
import { z } from 'zod'
import { generateToken } from '../../shared/ids'
type OAuthProviderOptions = Parameters<typeof oauthProvider>[0]
@@ -44,7 +44,7 @@ export function oauthPushedAuthorizationRequests(options: OAuthProviderOptions):
await validatePushedAuthorizationRequest(ctx.body, client, options)
const parameters = stripClientCredentials(ctx.body)
const requestUri = `${PAR_REQUEST_URI_PREFIX}${nanoid(32)}`
const requestUri = `${PAR_REQUEST_URI_PREFIX}${generateToken(33)}`
const now = new Date()
await ctx.context.adapter.create({
model: 'oauthPushedAuthorizationRequest',
+2
View File
@@ -1,6 +1,7 @@
import { createApp } from './app'
import { createAuth } from './auth'
import { createDeps } from './composition'
import { assertIdIntegrity } from './db/id-integrity'
import type { Platform } from './platform/interface'
import type { Deps } from './usecases/deps'
@@ -17,6 +18,7 @@ export async function createBootstrap(platform: Platform, deps: Deps = createDep
.map((o) => o.trim())
.filter(Boolean) || ['http://localhost:5185']
await assertIdIntegrity(platform.db)
const auth = await createAuth(platform, secret, baseURL, trustedOrigins)
return createApp(platform, auth, deps)
}
+177
View File
@@ -0,0 +1,177 @@
import { env } from 'cloudflare:workers'
import { describe, expect, it } from 'vitest'
import idBackfillWorker, {
applyIdBackfillArtifact,
finalizeIdBackfill,
type IdBackfillBatchArtifact,
} from '../../workers/id-backfill'
const SUBJECT = '_zpan_id_backfill_cf_subject'
const CHILD = '_zpan_id_backfill_cf_child'
const MAP = '_zpan_id_backfill_map'
const PUBLIC = '_zpan_id_backfill_cf_public'
const EVENT = '_zpan_id_backfill_cf_event'
const CREDENTIAL = '_zpan_id_backfill_cf_credential'
async function artifact(statements: string[]): Promise<IdBackfillBatchArtifact> {
const bytes = new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(statements))),
)
const digest = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
return { version: 1, digest, statements }
}
async function resetFixture(): Promise<void> {
await env.DB.exec(`
DROP TABLE IF EXISTS ${CHILD};
DROP TABLE IF EXISTS ${SUBJECT};
DROP TABLE IF EXISTS ${MAP};
DROP TABLE IF EXISTS ${PUBLIC};
DROP TABLE IF EXISTS ${EVENT};
DROP TABLE IF EXISTS ${CREDENTIAL};
CREATE TABLE ${SUBJECT} (id TEXT PRIMARY KEY);
CREATE TABLE ${CHILD} (id TEXT PRIMARY KEY, subject_id TEXT NOT NULL REFERENCES ${SUBJECT}(id));
CREATE TABLE ${MAP} (old_value TEXT PRIMARY KEY, new_value TEXT NOT NULL UNIQUE);
CREATE TABLE ${PUBLIC} (id TEXT PRIMARY KEY, token TEXT NOT NULL UNIQUE);
CREATE TABLE ${EVENT} (id TEXT PRIMARY KEY, event_key TEXT, metadata TEXT);
CREATE TABLE ${CREDENTIAL} (id TEXT PRIMARY KEY, secret TEXT);
CREATE TABLE IF NOT EXISTS system_options (key TEXT PRIMARY KEY, value TEXT NOT NULL);
DELETE FROM system_options WHERE key IN ('id_normalization_version', 'id_normalization_artifact_digest', 'id_normalization_pending_artifact_digest');
INSERT INTO ${SUBJECT} VALUES ('legacy_id');
INSERT INTO ${CHILD} VALUES ('child', 'legacy_id');
INSERT INTO ${PUBLIC} VALUES ('public', 'ds_legacy');
INSERT INTO ${EVENT} VALUES ('event:user:legacy_id', NULL, '{"matterId":"legacy_id","matterIds":["legacy_id"]}');
INSERT INTO ${CREDENTIAL} VALUES ('credential', 'secret');
`)
}
function representativeStatements(): string[] {
return [
'PRAGMA defer_foreign_keys = ON;',
`INSERT INTO ${MAP} VALUES ('legacy_id', 'Base62Replacement')`,
`UPDATE ${CHILD} SET subject_id = (SELECT new_value FROM ${MAP} WHERE old_value = subject_id)`,
`UPDATE ${EVENT} SET event_key = replace(id, 'legacy_id', 'Base62Replacement'), metadata = json_set(metadata, '$.matterId', 'Base62Replacement', '$.matterIds[0]', 'Base62Replacement')`,
`UPDATE ${SUBJECT} SET id = (SELECT new_value FROM ${MAP} WHERE old_value = id)`,
`UPDATE ${PUBLIC} SET token = 'PublicBase62Token'`,
`DELETE FROM ${CREDENTIAL}`,
]
}
describe('[CF] ID backfill D1 transaction rehearsal', () => {
it('defers FKs while atomically rewriting a PK and its reference', async () => {
await resetFixture()
const plan = await artifact(representativeStatements())
const response = await idBackfillWorker.fetch(
new Request('https://maintenance.invalid/', {
method: 'POST',
headers: {
Authorization: 'Bearer rehearsal-secret',
'Content-Type': 'application/json',
'X-ZPan-ID-Backfill-Confirm': 'invalidate-credentials-and-links',
'X-ZPan-ID-Backfill-Digest': plan.digest,
},
body: JSON.stringify(plan),
}),
{ DB: env.DB, ID_BACKFILL_AUTH_TOKEN: 'rehearsal-secret' },
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
ok: true,
statements: plan.statements.length,
digest: plan.digest,
})
const row = await env.DB.prepare(
`SELECT s.id, c.subject_id AS subjectId FROM ${SUBJECT} s JOIN ${CHILD} c ON c.subject_id = s.id`,
).first<{ id: string; subjectId: string }>()
expect(row).toEqual({ id: 'Base62Replacement', subjectId: 'Base62Replacement' })
const foreignKeys = await env.DB.prepare(`PRAGMA foreign_key_check(${CHILD})`).all()
expect(foreignKeys.results).toEqual([])
expect(await env.DB.prepare(`SELECT token FROM ${PUBLIC}`).first()).toEqual({ token: 'PublicBase62Token' })
expect(await env.DB.prepare(`SELECT event_key AS eventKey, metadata FROM ${EVENT}`).first()).toEqual({
eventKey: 'event:user:Base62Replacement',
metadata: '{"matterId":"Base62Replacement","matterIds":["Base62Replacement"]}',
})
expect(
(await env.DB.prepare(`SELECT COUNT(*) AS count FROM ${CREDENTIAL}`).first<{ count: number }>())?.count,
).toBe(0)
})
it('rolls the whole D1 batch back when a later statement fails', async () => {
await resetFixture()
const plan = await artifact([...representativeStatements(), `INSERT INTO ${SUBJECT} VALUES ('Base62Replacement')`])
await expect(applyIdBackfillArtifact(env.DB, plan, plan.digest)).rejects.toThrow()
const subject = await env.DB.prepare(`SELECT id FROM ${SUBJECT}`).first<{ id: string }>()
const child = await env.DB.prepare(`SELECT subject_id AS subjectId FROM ${CHILD}`).first<{ subjectId: string }>()
expect(subject?.id).toBe('legacy_id')
expect(child?.subjectId).toBe('legacy_id')
expect(await env.DB.prepare(`SELECT token FROM ${PUBLIC}`).first()).toEqual({ token: 'ds_legacy' })
expect(
(await env.DB.prepare(`SELECT COUNT(*) AS count FROM ${CREDENTIAL}`).first<{ count: number }>())?.count,
).toBe(1)
})
it('rejects a modified or mismatched artifact before sending a D1 batch', async () => {
await resetFixture()
const plan = await artifact(representativeStatements())
plan.statements[1] = `DELETE FROM ${SUBJECT}`
await expect(applyIdBackfillArtifact(env.DB, plan, plan.digest)).rejects.toThrow(
'backfill_artifact_digest_mismatch',
)
expect(await env.DB.prepare(`SELECT id FROM ${SUBJECT}`).first()).toEqual({ id: 'legacy_id' })
})
it('rejects an artifact that would exceed the D1 free-plan invocation budget', async () => {
await resetFixture()
const plan = await artifact(['PRAGMA defer_foreign_keys = ON;', ...Array.from({ length: 47 }, () => 'SELECT 1')])
await expect(applyIdBackfillArtifact(env.DB, plan, plan.digest)).rejects.toThrow('d1_query_limit_exceeded')
})
it('rejects an artifact after the backfill has been finalized', async () => {
await resetFixture()
await env.DB.prepare("INSERT INTO system_options VALUES ('id_normalization_version', '1')").run()
const plan = await artifact(representativeStatements())
await expect(applyIdBackfillArtifact(env.DB, plan, plan.digest)).rejects.toThrow('id_backfill_already_finalized:1')
expect(await env.DB.prepare(`SELECT id FROM ${SUBJECT}`).first()).toEqual({ id: 'legacy_id' })
})
it('finalizes atomically through the authenticated maintenance endpoint', async () => {
await resetFixture()
const plan = await artifact(representativeStatements())
await applyIdBackfillArtifact(env.DB, plan, plan.digest)
await expect(finalizeIdBackfill(env.DB, { version: 1, digest: plan.digest })).resolves.toEqual({
digest: plan.digest,
})
await resetFixture()
await applyIdBackfillArtifact(env.DB, plan, plan.digest)
const response = await idBackfillWorker.fetch(
new Request('https://maintenance.invalid/finalize', {
method: 'POST',
headers: {
Authorization: 'Bearer rehearsal-secret',
'Content-Type': 'application/json',
'X-ZPan-ID-Backfill-Confirm': 'finalize-id-normalization',
},
body: JSON.stringify({ version: 1, digest: plan.digest }),
}),
{ DB: env.DB, ID_BACKFILL_AUTH_TOKEN: 'rehearsal-secret' },
)
expect(response.status).toBe(200)
expect(
await env.DB.prepare("SELECT value FROM system_options WHERE key = 'id_normalization_version'").first(),
).toEqual({
value: '1',
})
expect(
await env.DB.prepare("SELECT name FROM sqlite_master WHERE name = '_zpan_id_backfill_map'").first(),
).toBeNull()
await expect(finalizeIdBackfill(env.DB, { version: 1, digest: plan.digest })).rejects.toThrow(
'id_backfill_already_finalized:1',
)
})
})
+88
View File
@@ -0,0 +1,88 @@
import { sql } from 'drizzle-orm'
import { describe, expect, it, vi } from 'vitest'
import { createTestApp } from '../test/setup'
import { assertIdIntegrity } from './id-integrity'
describe('ID integrity release guard', () => {
it('marks a fresh empty database once and later starts through one checkpoint query', async () => {
const { app, db } = await createTestApp()
// createTestApp initializes Better Auth first; production bootstrap runs
// the integrity guard before auth creates its initial signing key.
await db.run(sql`DELETE FROM jwks`)
await db.run(sql`DELETE FROM oauthResource`)
await expect(assertIdIntegrity(db)).resolves.toBeUndefined()
expect(
await db.all<{ value: string }>(sql`
SELECT value FROM system_options WHERE key = 'id_normalization_version'
`),
).toEqual([{ value: '1' }])
await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'ID Guard', email: 'id-guard@example.com', password: 'password123456' }),
})
const all = vi.spyOn(db, 'all')
await expect(assertIdIntegrity(db)).resolves.toBeUndefined()
expect(all).toHaveBeenCalledTimes(1)
all.mockRestore()
})
it('rejects malformed completion and pending checkpoints', async () => {
const { db } = await createTestApp()
await db.run(sql`INSERT INTO system_options (key, value) VALUES ('id_normalization_version', '2')`)
await expect(assertIdIntegrity(db)).rejects.toThrow('id_integrity_checkpoint_invalid:id_normalization_version')
await db.run(sql`DELETE FROM system_options WHERE key = 'id_normalization_version'`)
await db.run(sql`
INSERT INTO system_options (key, value)
VALUES ('id_normalization_pending_artifact_digest', 'not-a-digest')
`)
await expect(assertIdIntegrity(db)).rejects.toThrow(
'id_integrity_checkpoint_invalid:id_normalization_pending_artifact_digest',
)
})
it('does not mark an upgrade database with only a retained structured reference', async () => {
const { db } = await createTestApp()
await db.run(sql`DELETE FROM jwks`)
await db.run(sql`DELETE FROM oauthResource`)
await db.run(sql`
INSERT INTO resource_changes (
scope_type, scope_id, resource_type, resource_id, change_type, metadata, occurred_at
) VALUES ('organization', 'legacy_org', 'share', 'legacy-share', 'upsert', '{"shareId":"legacy-share"}', 1)
`)
await expect(assertIdIntegrity(db)).rejects.toThrow('id_normalization_checkpoint_required')
expect(await db.all(sql`SELECT value FROM system_options WHERE key = 'id_normalization_version'`)).toEqual([])
})
it('does not mark an upgrade database with only an invalidated credential row', async () => {
const { db } = await createTestApp()
await db.run(sql`DELETE FROM jwks`)
await db.run(sql`DELETE FROM oauthResource`)
await db.run(sql`INSERT INTO oauthClientAssertion (id, expires_at) VALUES ('legacy_assertion', 1)`)
await expect(assertIdIntegrity(db)).rejects.toThrow('id_normalization_checkpoint_required')
expect(await db.all(sql`SELECT value FROM system_options WHERE key = 'id_normalization_version'`)).toEqual([])
})
it('fails fast with counts but does not print identifier values', async () => {
const { db } = await createTestApp()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('secret_bad-id', 'bucket', 'https://s3.example.com', 'auto', 'key', 'secret', '', '', 0, 0, 'active', 0, 0)
`)
await expect(assertIdIntegrity(db)).rejects.toThrow('id_integrity_failed:storages.id=1')
await expect(assertIdIntegrity(db)).rejects.not.toThrow('secret_bad-id')
})
it('fails when a redirect resource is missing its transactional reservation', async () => {
const { db } = await createTestApp()
await db.run(sql`
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, status, created_at)
VALUES ('ShareId1', 'SharedToken1', 'direct', 'MatterId1', 'OrgId1', 'UserId1', 'active', 0)
`)
await expect(assertIdIntegrity(db)).rejects.toThrow('redirect_tokens.registry=1')
})
})
+176
View File
@@ -0,0 +1,176 @@
import { ID_NORMALIZATION_DATA_TABLES } from '@shared/id-normalization-inventory'
import { sql } from 'drizzle-orm'
import type { Database } from '../platform/interface'
const ID_TABLES = [
'user',
'session',
'account',
'verification',
'jwks',
'organization',
'member',
'invitation',
'apikey',
'deviceCode',
'oauthClient',
'oauthResource',
'oauthClientResource',
'oauthRefreshToken',
'oauthAccessToken',
'oauthConsent',
'oauthPushedAuthorizationRequest',
'downloader_bootstrap_credentials',
'matters',
'webdav_dead_properties',
'webdav_locks',
'storages',
'org_quotas',
'cloud_traffic_reports',
'org_quota_entitlements',
'webhook_events',
'x402_capacity_purchase_intents',
'invite_codes',
'site_invitations',
'license_bindings',
'team_invite_links',
'notifications',
'background_jobs',
'downloaders',
'download_tasks',
'object_upload_sessions',
'remote_download_usage_reports',
'announcements',
'audit_events',
'stats_rollups_hourly',
'storage_usage_ledger',
'shares',
'share_recipients',
'image_hostings',
] as const
const TOKEN_COLUMNS = [
['matters', 'alias'],
['invite_codes', 'code'],
['site_invitations', 'token'],
['team_invite_links', 'token'],
['shares', 'token'],
['image_hostings', 'token'],
['image_hosting_configs', 'verification_token'],
['downloaders', 'token_jti'],
] as const
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`
const literal = (value: string) => `'${value.replaceAll("'", "''")}'`
const invalid = (column: string) => `${quote(column)} = '' OR ${quote(column)} GLOB '*[^A-Za-z0-9]*'`
const COMPLETION_VERSION = '1'
const DIGEST_PATTERN = /^[0-9a-f]{64}$/
type ReleaseCheckpoint = { key: string; value: string }
function validateReleaseCheckpoint(rows: ReleaseCheckpoint[]): boolean {
const completion = rows.find(({ key }) => key === 'id_normalization_version')
const pending = rows.find(({ key }) => key === 'id_normalization_pending_artifact_digest')
if (completion && completion.value !== COMPLETION_VERSION) {
throw new Error(`id_integrity_checkpoint_invalid:id_normalization_version`)
}
if (pending && !DIGEST_PATTERN.test(pending.value)) {
throw new Error(`id_integrity_checkpoint_invalid:id_normalization_pending_artifact_digest`)
}
return completion !== undefined || pending !== undefined
}
function markFreshEmptyDatabaseSql(): string {
const emptyPredicates = ID_NORMALIZATION_DATA_TABLES.map(
(table) => `NOT EXISTS (SELECT 1 FROM ${quote(table)} LIMIT 1)`,
)
emptyPredicates.push("NOT EXISTS (SELECT 1 FROM system_options WHERE key = 'instance_id' LIMIT 1)")
return `INSERT INTO system_options (key, value)
SELECT 'id_normalization_version', '${COMPLETION_VERSION}'
WHERE ${emptyPredicates.join(' AND ')}
ON CONFLICT(key) DO NOTHING
RETURNING key, value`
}
export function idIntegrityScanSql(): string[] {
return [
...ID_TABLES.map(
(table) =>
`SELECT ${literal(`${table}.id`)} AS field, COUNT(*) AS count FROM ${quote(table)} WHERE ${invalid('id')}`,
),
...TOKEN_COLUMNS.map(
([table, column]) =>
`SELECT ${literal(`${table}.${column}`)} AS field, COUNT(*) AS count FROM ${quote(table)} WHERE ${quote(column)} IS NOT NULL AND (${invalid(column)})`,
),
"SELECT 'system_options.instance_id' AS field, COUNT(*) AS count FROM system_options WHERE key = 'instance_id' AND (value = '' OR value GLOB '*[^A-Za-z0-9]*')",
"SELECT 'redirect_token_registry.token' AS field, COUNT(*) AS count FROM redirect_token_registry WHERE token = '' OR token GLOB '*[^A-Za-z0-9]*'",
"SELECT 'redirect_token_registry.resource_id' AS field, COUNT(*) AS count FROM redirect_token_registry WHERE resource_id = '' OR resource_id GLOB '*[^A-Za-z0-9]*'",
]
}
export const AMBIGUOUS_REDIRECT_SQL =
"SELECT COUNT(*) AS count FROM shares s INNER JOIN image_hostings i ON i.token = s.token WHERE s.kind = 'direct' AND s.status = 'active' AND i.status = 'active'"
export const REDIRECT_REGISTRY_INTEGRITY_SQL = `SELECT COUNT(*) AS count FROM redirect_token_registry r
WHERE (r.kind = 'direct_share' AND NOT EXISTS (
SELECT 1 FROM shares s WHERE s.kind = 'direct' AND s.id = r.resource_id AND s.token = r.token
)) OR (r.kind = 'image_hosting' AND NOT EXISTS (
SELECT 1 FROM image_hostings i WHERE i.id = r.resource_id AND i.token = r.token
)) OR r.kind NOT IN ('direct_share', 'image_hosting')
UNION ALL
SELECT COUNT(*) AS count FROM shares s
WHERE s.kind = 'direct' AND NOT EXISTS (
SELECT 1 FROM redirect_token_registry r WHERE r.kind = 'direct_share' AND r.resource_id = s.id AND r.token = s.token
)
UNION ALL
SELECT COUNT(*) AS count FROM image_hostings i
WHERE NOT EXISTS (
SELECT 1 FROM redirect_token_registry r WHERE r.kind = 'image_hosting' AND r.resource_id = i.id AND r.token = i.token
)`
export async function assertIdIntegrity(db: Database): Promise<void> {
let releaseCheckpoint = (await db.all(
sql.raw(
"SELECT key, value FROM system_options WHERE key IN ('id_normalization_version', 'id_normalization_pending_artifact_digest')",
),
)) as ReleaseCheckpoint[]
// The D1 maintenance executor writes the pending digest atomically with the
// backfill and finalize replaces it with the completion marker after full
// semantic scans. Avoid repeating unindexed release scans on every isolate.
if (validateReleaseCheckpoint(releaseCheckpoint)) return
const initialized = (await db.all(sql.raw(markFreshEmptyDatabaseSql()))) as ReleaseCheckpoint[]
if (initialized.length > 0) return
// A concurrent isolate may have installed the same empty-database marker.
releaseCheckpoint = (await db.all(
sql.raw(
"SELECT key, value FROM system_options WHERE key IN ('id_normalization_version', 'id_normalization_pending_artifact_digest')",
),
)) as ReleaseCheckpoint[]
if (validateReleaseCheckpoint(releaseCheckpoint)) return
const scans = idIntegrityScanSql()
const rows: Array<{ field: string; count: number }> = []
// D1 caps compound SELECT terms below SQLite's upstream default, so keep each
// release-boundary scan comfortably below that limit.
for (let offset = 0; offset < scans.length; offset += 5) {
rows.push(
...((await db.all(sql.raw(scans.slice(offset, offset + 5).join(' UNION ALL ')))) as Array<{
field: string
count: number
}>),
)
}
const failures = rows.filter(({ count }) => count > 0)
const ambiguous = (await db.all(sql.raw(AMBIGUOUS_REDIRECT_SQL))) as Array<{ count: number }>
if ((ambiguous[0]?.count ?? 0) > 0) failures.push({ field: 'redirect_tokens.ambiguous', count: ambiguous[0]!.count })
const registryFailures = (await db.all(sql.raw(REDIRECT_REGISTRY_INTEGRITY_SQL))) as Array<{ count: number }>
const registryFailureCount = registryFailures.reduce((total, row) => total + row.count, 0)
if (registryFailureCount > 0) failures.push({ field: 'redirect_tokens.registry', count: registryFailureCount })
if (failures.length > 0) {
throw new Error(`id_integrity_failed:${failures.map(({ field, count }) => `${field}=${count}`).join(',')}`)
}
// A non-empty database cannot prove that already-Base62 public tokens were
// rotated or credentials invalidated through value scans alone. Only the
// rehearsal-backed apply/finalize flow may establish that release boundary.
throw new Error('id_normalization_checkpoint_required')
}
+64
View File
@@ -82,6 +82,70 @@ describe('migration 0022_kind_storm.sql', () => {
})
})
describe('migration 0092_base62_audit_event_key.sql', () => {
const migrationPath = join(process.cwd(), 'migrations/0092_base62_audit_event_key.sql')
const migration = readFileSync(migrationPath, 'utf-8')
it('adds a nullable unique idempotency key without rewriting audit primary IDs', () => {
const db = new Database(':memory:')
try {
db.exec('CREATE TABLE audit_events (id TEXT PRIMARY KEY)')
db.exec("INSERT INTO audit_events (id) VALUES ('legacy-event-id')")
for (const statement of migration.split('--> statement-breakpoint')) db.exec(statement)
expect(db.prepare('SELECT id, event_key FROM audit_events').get()).toEqual({
id: 'legacy-event-id',
event_key: null,
})
db.exec("INSERT INTO audit_events (id, event_key) VALUES ('new-id', 'event:key')")
expect(() => db.exec("INSERT INTO audit_events (id, event_key) VALUES ('other-id', 'event:key')")).toThrow()
} finally {
db.close()
}
})
})
describe('migration 0093_redirect-token-registry.sql', () => {
const migrationPath = join(process.cwd(), 'migrations/0093_redirect-token-registry.sql')
const migration = readFileSync(migrationPath, 'utf-8')
it('creates a shared unique namespace for redirect token reservations', () => {
const db = new Database(':memory:')
try {
for (const statement of migration.split('--> statement-breakpoint')) db.exec(statement)
db.exec("INSERT INTO redirect_token_registry VALUES ('SharedToken1', 'direct_share', 'ShareId1')")
expect(() =>
db.exec("INSERT INTO redirect_token_registry VALUES ('SharedToken1', 'image_hosting', 'ImageId1')"),
).toThrow()
expect(() =>
db.exec("INSERT INTO redirect_token_registry VALUES ('OtherToken2', 'image_hosting', 'ShareId1')"),
).toThrow()
} finally {
db.close()
}
})
})
describe('migration 0094_redirect-token-kind-resource.sql', () => {
const createMigration = readFileSync(join(process.cwd(), 'migrations/0093_redirect-token-registry.sql'), 'utf-8')
const migration = readFileSync(join(process.cwd(), 'migrations/0094_redirect-token-kind-resource.sql'), 'utf-8')
it('allows independent resource ID namespaces while retaining per-kind uniqueness', () => {
const db = new Database(':memory:')
try {
for (const statement of createMigration.split('--> statement-breakpoint')) db.exec(statement)
for (const statement of migration.split('--> statement-breakpoint')) db.exec(statement)
db.exec("INSERT INTO redirect_token_registry VALUES ('ShareToken1', 'direct_share', 'SameId1')")
db.exec("INSERT INTO redirect_token_registry VALUES ('ImageToken1', 'image_hosting', 'SameId1')")
expect(() =>
db.exec("INSERT INTO redirect_token_registry VALUES ('ShareToken2', 'direct_share', 'SameId1')"),
).toThrow()
} finally {
db.close()
}
})
})
describe('migration 0067_storage-health-status-default.sql', () => {
const migrationPath = join(process.cwd(), 'migrations/0067_storage-health-status-default.sql')
const migration = readFileSync(migrationPath, 'utf-8')
+16 -2
View File
@@ -529,6 +529,7 @@ export const auditEvents = sqliteTable(
'audit_events',
{
id: text('id').primaryKey(),
eventKey: text('event_key').unique(),
orgId: text('org_id').notNull(),
userId: text('user_id'),
action: text('action').notNull(), // 'upload', 'create', 'delete', 'rename', 'move', 'restore'
@@ -644,6 +645,19 @@ export const shares = sqliteTable(
],
)
// Shared namespace for resources resolved by /r/:token. Keeping the reservation
// in the same transaction as the resource insert makes cross-table uniqueness
// an invariant instead of a best-effort preflight check.
export const redirectTokenRegistry = sqliteTable(
'redirect_token_registry',
{
token: text('token').primaryKey(),
kind: text('kind').notNull(), // 'direct_share' | 'image_hosting'
resourceId: text('resource_id').notNull(),
},
(t) => [uniqueIndex('redirect_token_registry_kind_resource_id_unique').on(t.kind, t.resourceId)],
)
export const shareRecipients = sqliteTable(
'share_recipients',
{
@@ -682,11 +696,11 @@ export const imageHostingConfigs = sqliteTable('image_hosting_configs', {
export const imageHostings = sqliteTable(
'image_hostings',
{
id: text('id').primaryKey(), // nanoid(12)
id: text('id').primaryKey(), // 13-character Base62 ID
orgId: text('org_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(), // "ih" + 10 alphanumeric characters
token: text('token').notNull().unique(), // 12-character Base62 public token
path: text('path').notNull(), // virtual path e.g. "blog/2026/04/shot.png"
storageId: text('storage_id')
.notNull()
-4
View File
@@ -35,10 +35,6 @@ export interface ImageUrlConfig {
domainVerifiedAt: Date | null
}
export function isImageHostingToken(token: string): boolean {
return token.startsWith('ih')
}
export function buildImageUrl(config: ImageUrlConfig | null, path: string, tokenUrl: string): string {
if (config?.customDomain && config.domainVerifiedAt) {
return `https://${config.customDomain}/${path}`
+1 -1
View File
@@ -1200,7 +1200,7 @@ async function seedStatsFixture(db: Awaited<ReturnType<typeof createTestApp>>['d
await db.run(sql`
UPDATE audit_events
SET created_at = ${nowSec}
WHERE id = ${`event:user_register:${userId}`}
WHERE event_key = ${`event:user_register:${userId}`}
`)
await db.run(sql`
@@ -122,7 +122,7 @@ describe('better-auth admin user endpoints (migration target)', () => {
const registrationBefore = await db.all<{ provider: string }>(sql`
SELECT json_extract(metadata, '$.provider') AS provider
FROM audit_events
WHERE id = ${`event:user_register:${userId}`}
WHERE event_key = ${`event:user_register:${userId}`}
`)
expect(registrationBefore).toEqual([{ provider: 'credential' }])
@@ -146,7 +146,7 @@ describe('better-auth admin user endpoints (migration target)', () => {
const registrationAfter = await db.all<{ provider: string }>(sql`
SELECT json_extract(metadata, '$.provider') AS provider
FROM audit_events
WHERE id = ${`event:user_register:${userId}`}
WHERE event_key = ${`event:user_register:${userId}`}
`)
expect(registrationAfter).toEqual(registrationBefore)
})
+7 -6
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { createBackgroundJobRequestSchema, cursorPageSchema, listBackgroundJobsQuerySchema } from '../../shared/schemas'
import { opaqueIdSchema } from '../../shared/schemas/identifiers'
import type { Env } from '../middleware/platform'
import {
cancelBackgroundJob,
@@ -30,9 +31,9 @@ const backgroundJobProgressSchema = z.object({
const backgroundJobSchema = z
.object({
id: z.string(),
orgId: z.string(),
userId: z.string(),
id: opaqueIdSchema,
orgId: opaqueIdSchema,
userId: opaqueIdSchema,
type: z.string(),
status: z.string(),
targetFolder: z.string().nullable(),
@@ -119,7 +120,7 @@ const getJobRoute = authRoute(
tags: ['Background Jobs'],
method: 'get',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
200: jsonContent(backgroundJobSchema, 'Background job'),
404: errorResponse('Not found'),
@@ -135,7 +136,7 @@ const cancelJobRoute = authRoute(
tags: ['Background Jobs'],
method: 'put',
path: '/{id}/status',
request: { params: z.object({ id: z.string() }), ...jsonBody(cancelJobSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(cancelJobSchema) },
responses: {
200: jsonContent(backgroundJobSchema, 'Canceled background job'),
404: errorResponse('Not found'),
@@ -152,7 +153,7 @@ const retryJobRoute = authRoute(
tags: ['Background Jobs'],
method: 'post',
path: '/{id}/retries',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
201: jsonContent(backgroundJobSchema, 'Retried background job'),
404: errorResponse('Not found'),
+7 -6
View File
@@ -11,6 +11,7 @@ import {
listDownloadTasksQuerySchema,
updateDownloadTaskSchema,
} from '@shared/schemas'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import type { Env } from '../../middleware/platform'
import {
createDownloadTask,
@@ -162,7 +163,7 @@ const getRoute = authRoute(
tags: ['Download Tasks'],
method: 'get',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
200: jsonContent(downloadTaskSchema, 'Download task'),
...taskErrorResponses,
@@ -178,7 +179,7 @@ const eventsRoute = authRoute(
tags: ['Download Tasks'],
method: 'get',
path: '/{id}/events',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
200: jsonContent(downloadTaskTimelineSchema, 'Download task timeline'),
...taskErrorResponses,
@@ -194,7 +195,7 @@ const updateRoute = authRoute(
tags: ['Download Tasks'],
method: 'patch',
path: '/{id}',
request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloadTaskSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(updateDownloadTaskSchema) },
responses: {
200: jsonContent(downloadTaskSchema, 'Updated download task'),
...taskErrorResponses,
@@ -210,7 +211,7 @@ const statusRoute = authRoute(
tags: ['Download Tasks'],
method: 'put',
path: '/{id}/status',
request: { params: z.object({ id: z.string() }), ...jsonBody(downloadTaskStatusUpdateSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(downloadTaskStatusUpdateSchema) },
responses: {
200: jsonContent(downloadTaskSchema, 'Updated download task'),
...taskErrorResponses,
@@ -226,7 +227,7 @@ const attemptRoute = authRoute(
tags: ['Download Tasks'],
method: 'post',
path: '/{id}/attempts',
request: { params: z.object({ id: z.string() }), ...jsonBody(downloadTaskAttemptSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(downloadTaskAttemptSchema) },
responses: {
201: jsonContent(downloadTaskSchema, 'New download attempt'),
...taskErrorResponses,
@@ -242,7 +243,7 @@ const deleteRoute = authRoute(
tags: ['Download Tasks'],
method: 'delete',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
204: { description: 'Deleted download task' },
...taskErrorResponses,
+4 -3
View File
@@ -10,6 +10,7 @@ import {
updateDownloaderCreditBillingSchema,
updateDownloaderSchema,
} from '@shared/schemas'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import { FREE_DOWNLOADER_LIMIT } from '../../../shared/constants'
import { hasFeature } from '../../domain/licensing'
import type { Env } from '../../middleware/platform'
@@ -71,7 +72,7 @@ const updateRoute = authRoute(
tags: ['Downloaders'],
method: 'patch',
path: '/{id}',
request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(updateDownloaderSchema) },
responses: {
200: jsonContent(downloaderSchema, 'Updated downloader'),
402: errorResponse('Feature not available'),
@@ -88,7 +89,7 @@ const updateCreditBillingRoute = authRoute(
tags: ['Downloaders'],
method: 'put',
path: '/{id}/credit-billing',
request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderCreditBillingSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(updateDownloaderCreditBillingSchema) },
responses: {
200: jsonContent(downloaderSchema, 'Updated downloader'),
402: errorResponse('Feature not available'),
@@ -105,7 +106,7 @@ const deleteRoute = authRoute(
tags: ['Downloaders'],
method: 'delete',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
204: { description: 'Deleted downloader' },
404: errorResponse('Not found'),
+4 -3
View File
@@ -1,13 +1,14 @@
import { z } from '@hono/zod-openapi'
import { pageSchema } from '@shared/schemas'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import type { EntitlementResult, QuotaEntitlementItem } from '../usecases/ports'
// Quota entitlement DTO shared by the team- and user-scoped admin endpoints. The
// domain record carries Date timestamps; toQuotaEntitlementDTO serializes them.
export const quotaEntitlementSchema = z
.object({
id: z.string(),
orgId: z.string(),
id: opaqueIdSchema,
orgId: opaqueIdSchema,
resourceType: z.string(),
entitlementType: z.string(),
source: z.string(),
@@ -35,7 +36,7 @@ export function toQuotaEntitlementDTO(e: QuotaEntitlementItem): QuotaEntitlement
}
export const entitlementResultSchema = z
.object({ orgId: z.string(), entitlement: quotaEntitlementSchema })
.object({ orgId: opaqueIdSchema, entitlement: quotaEntitlementSchema })
.openapi('EntitlementResult')
export function toEntitlementResultDTO(r: EntitlementResult): z.infer<typeof entitlementResultSchema> {
@@ -309,9 +309,14 @@ describe('POST /api/image-hosting/images/presign (JSON two-stage)', () => {
const body = (await res.json()) as Record<string, unknown>
expect(body.uploadUrl).toBe('https://presigned-upload.example.com')
expect(body.id).toBeTruthy()
expect(String(body.token)).toMatch(/^ih[A-Za-z0-9]{10}$/)
expect(String(body.token)).toMatch(/^[A-Za-z0-9]{12}$/)
expect(body.path).toBe('blog/2026/shot.png')
expect(String(body.storageKey)).toMatch(/^ih\//)
expect(
await db.all(
sql`SELECT token, kind, resource_id AS resourceId FROM redirect_token_registry WHERE token = ${String(body.token)}`,
),
).toEqual([{ token: body.token, kind: 'image_hosting', resourceId: body.id }])
})
it('returns 400 for path with .. [spec: image-hosting/path-traversal]', async () => {
@@ -589,7 +594,7 @@ describe('POST /api/image-hosting/images (multipart)', () => {
const body = (await res.json()) as { data: Record<string, unknown> }
expect(body.data).toBeDefined()
expect(body.data.url).toBeTruthy()
expect(String(body.data.urlAlt)).toMatch(/\/r\/ih[A-Za-z0-9]{10}$/)
expect(String(body.data.urlAlt)).toMatch(/\/r\/[A-Za-z0-9]{12}$/)
expect(String(body.data.markdown)).toContain('![](')
expect(String(body.data.html)).toContain('<img src=')
expect(String(body.data.bbcode)).toContain('[img]')
@@ -724,7 +729,7 @@ describe('POST /api/image-hosting/images (multipart)', () => {
const body = (await res.json()) as { data: Record<string, unknown> }
// url and urlAlt should both be the token URL when no custom domain
expect(body.data.url).toBe(body.data.urlAlt)
expect(String(body.data.url)).toMatch(/\/r\/ih[A-Za-z0-9]{10}$/)
expect(String(body.data.url)).toMatch(/\/r\/[A-Za-z0-9]{12}$/)
})
it('cleans up DB row and refunds quota when S3 put fails', async () => {
@@ -1209,6 +1214,23 @@ describe('DELETE /api/image-hosting/images/:id', () => {
expect(res.status).toBe(404)
})
it('removes the redirect token reservation when a draft is hard-deleted', async () => {
const { app, db } = await createTestApp()
await insertStorage(db)
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
await insertImageHostingConfig(db, orgId)
const createRes = await app.request('/api/image-hosting/images/presign', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: 'discard-draft.png', mime: 'image/png', size: 2048 }),
})
const image = (await createRes.json()) as { id: string; token: string }
expect((await app.request(`/api/image-hosting/images/${image.id}`, { method: 'DELETE', headers })).status).toBe(204)
expect(await db.all(sql`SELECT token FROM redirect_token_registry WHERE resource_id = ${image.id}`)).toEqual([])
})
it('removes the S3 object and retains a hidden DB tombstone', async () => {
const { app, db } = await createTestApp()
await insertStorage(db)
+13 -12
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { nanoid } from 'nanoid'
import { generateToken } from '@shared/ids'
import { opaqueIdSchema, opaqueTokenSchema } from '@shared/schemas/identifiers'
import {
ALLOWED_IMAGE_MIMES,
createIhostImageSchema,
@@ -42,12 +43,12 @@ import {
// them as Date).
const imageHostingSchema = z
.object({
id: z.string(),
orgId: z.string(),
token: z.string(),
id: opaqueIdSchema,
orgId: opaqueIdSchema,
token: opaqueTokenSchema,
path: z.string(),
url: z.string(),
storageId: z.string(),
storageId: opaqueIdSchema,
storageKey: z.string(),
size: z.number().int(),
mime: z.string(),
@@ -78,8 +79,8 @@ function toImageHostingDTO(
const imageDraftSchema = z
.object({
id: z.string(),
token: z.string(),
id: opaqueIdSchema,
token: opaqueTokenSchema,
path: z.string(),
uploadUrl: z.string(),
storageKey: z.string(),
@@ -87,12 +88,12 @@ const imageDraftSchema = z
.openapi('ImageHostingDraft')
const imageListSchema = z
.object({ items: z.array(imageHostingSchema), nextPageToken: z.string().nullable() })
.object({ items: z.array(imageHostingSchema), nextPageToken: opaqueTokenSchema.nullable() })
.openapi('ImageHostingList')
// Derive a storage path from the upload's filename, falling back to a random name.
function deriveDefaultPath(filename: string, mime: string): string {
if (!filename || filename === 'blob') return `image-${nanoid(8)}.${mimeToExt(mime)}`
if (!filename || filename === 'blob') return `image-${generateToken(9)}.${mimeToExt(mime)}`
return filename.replace(/[/\\]/g, '_')
}
@@ -161,7 +162,7 @@ const getRoute = authRoute(
tags: ['Image Hosting'],
method: 'get',
path: '/images/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
200: jsonContent(imageHostingSchema, 'Hosted image'),
400: errorResponse('No active organization'),
@@ -179,7 +180,7 @@ const confirmRoute = authRoute(
tags: ['Image Hosting'],
method: 'put',
path: '/images/{id}/status',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
200: jsonContent(imageHostingSchema, 'Confirmed image'),
400: errorResponse('No active organization'),
@@ -198,7 +199,7 @@ const deleteRoute = authRoute(
tags: ['Image Hosting'],
method: 'delete',
path: '/images/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
204: { description: 'Deleted' },
400: errorResponse('No active organization'),
+4 -3
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { cursorPageSchema, listNotificationsQuerySchema } from '@shared/schemas'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import type { Env } from '../middleware/platform'
import {
getUnreadCount,
@@ -19,8 +20,8 @@ import {
const notificationSchema = z
.object({
id: z.string(),
userId: z.string(),
id: opaqueIdSchema,
userId: opaqueIdSchema,
type: z.string(),
title: z.string(),
body: z.string(),
@@ -88,7 +89,7 @@ const markReadRoute = authRoute(
tags: ['Notifications'],
method: 'patch',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
204: { description: 'Marked read' },
404: errorResponse('Not found'),
+8 -7
View File
@@ -14,6 +14,7 @@ import {
} from '@shared/schemas'
import type { Context } from 'hono'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { opaqueIdSchema, opaqueTokenSchema } from '../../shared/schemas/identifiers'
import { transferAuditActor } from '../middleware/audit-transfers'
import { boundWorkspaceOrgId, type Env } from '../middleware/platform'
import {
@@ -45,9 +46,9 @@ import { decodeOptionalPageToken, directoryCursorCodec, encodeNextPageToken, pag
// OpenAPI document and SDKs derive the `Matter` model from.
const matterSchema = z
.object({
id: z.string(),
orgId: z.string(),
alias: z.string(),
id: opaqueIdSchema,
orgId: opaqueIdSchema,
alias: opaqueTokenSchema,
name: z.string(),
type: z.string(),
size: z.number().int().nullable(),
@@ -56,7 +57,7 @@ const matterSchema = z
.string()
.describe('Slash-delimited parent folder path relative to the workspace root; empty for root objects.'),
object: z.string(),
storageId: z.string(),
storageId: opaqueIdSchema,
status: z.string(),
trashedAt: z.number().int().nullable(),
createdAt: z.string(),
@@ -156,11 +157,11 @@ const listObjectsQuerySchema = cursorPageQuerySchema.extend({
.optional(),
type: z.string().optional(),
search: z.string().optional(),
orgId: z.string().optional(),
orgId: opaqueIdSchema.optional(),
})
const idParam = z.object({ id: z.string() })
const sessionParams = z.object({ id: z.string(), uploadSessionId: z.string() })
const idParam = z.object({ id: opaqueIdSchema })
const sessionParams = z.object({ id: opaqueIdSchema, uploadSessionId: opaqueIdSchema })
const abortUploadQuerySchema = z.object({
strictStorageCleanup: z.enum(['1', 'true']).optional(),
})
+20 -3
View File
@@ -1,3 +1,4 @@
import { encodeBase62Bytes } from '@shared/ids'
import { describe, expect, it } from 'vitest'
import type { Platform } from '../platform/interface'
import {
@@ -20,7 +21,11 @@ function platform(secret = 'test-secret'): Platform {
}
async function signRawBody(body: string, secret: string): Promise<string> {
const encodedBody = Buffer.from(body).toString('base64url')
const payload = new TextEncoder().encode(body)
const signed = new Uint8Array(5 + payload.length)
signed[0] = 1
new DataView(signed.buffer).setUint32(1, payload.length)
signed.set(payload, 5)
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(`zpan:page-token:v1:${secret}`),
@@ -28,8 +33,11 @@ async function signRawBody(body: string, secret: string): Promise<string> {
false,
['sign'],
)
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(encodedBody))
return `${encodedBody}.${Buffer.from(signature).toString('base64url')}`
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, signed))
const envelope = new Uint8Array(signed.length + signature.length)
envelope.set(signed)
envelope.set(signature, signed.length)
return encodeBase62Bytes(envelope)
}
describe('page tokens', () => {
@@ -41,6 +49,8 @@ describe('page tokens', () => {
now: 1_000,
})
expect(query).toMatch(/^[A-Za-z0-9]+$/)
expect(token).toMatch(/^[A-Za-z0-9]+$/)
await expect(decodePageToken(platform(), token, { query, now: 2_000 })).resolves.toEqual({
createdAt: 123,
id: 'item-1',
@@ -131,4 +141,11 @@ describe('page tokens', () => {
meta: { reason: 'INVALID_PAGE_TOKEN' },
})
})
it('rejects the pre-release Base64url dotted format without a legacy decoder', async () => {
await expect(decodePageToken(platform(), 'eyJ2IjoxfQ.signature', { query: 'query' })).rejects.toMatchObject({
httpStatus: 400,
meta: { reason: 'INVALID_PAGE_TOKEN' },
})
})
})
+29 -25
View File
@@ -1,9 +1,12 @@
import { decodeBase62Bytes, encodeBase62Bytes, isBase62 } from '@shared/ids'
import type { Platform } from '../platform/interface'
import { badRequest } from '../usecases/ports'
const TOKEN_VERSION = 1
const TOKEN_TTL_MS = 72 * 60 * 60 * 1000
const TOKEN_PURPOSE = 'zpan:page-token:v1'
const TOKEN_HEADER_BYTES = 5
const TOKEN_SIGNATURE_BYTES = 32
export type PageBoundary = Record<string, string | number | null>
@@ -23,14 +26,6 @@ function invalidPageToken(): never {
throw badRequest('Invalid page token', 'INVALID_PAGE_TOKEN')
}
function encodeBase64Url(value: Uint8Array): string {
return Buffer.from(value).toString('base64url')
}
function decodeBase64Url(value: string): Uint8Array {
return new Uint8Array(Buffer.from(value, 'base64url'))
}
function secret(platform: Platform): string {
const value = platform.getEnv('BETTER_AUTH_SECRET')
if (!value) throw new Error('BETTER_AUTH_SECRET is required')
@@ -49,7 +44,7 @@ async function signingKey(platform: Platform): Promise<CryptoKey> {
export async function pageQueryFingerprint(value: unknown): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(value)))
return encodeBase64Url(new Uint8Array(digest))
return encodeBase62Bytes(new Uint8Array(digest))
}
export async function encodePageToken(
@@ -62,9 +57,16 @@ export async function encodePageToken(
query: input.query,
expiresAt: (input.now ?? Date.now()) + TOKEN_TTL_MS,
}
const body = encodeBase64Url(new TextEncoder().encode(JSON.stringify(payload)))
const signature = await crypto.subtle.sign('HMAC', await signingKey(platform), new TextEncoder().encode(body))
return `${body}.${encodeBase64Url(new Uint8Array(signature))}`
const payloadBytes = new TextEncoder().encode(JSON.stringify(payload))
const signed = new Uint8Array(TOKEN_HEADER_BYTES + payloadBytes.length)
signed[0] = TOKEN_VERSION
new DataView(signed.buffer).setUint32(1, payloadBytes.length)
signed.set(payloadBytes, TOKEN_HEADER_BYTES)
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', await signingKey(platform), signed))
const envelope = new Uint8Array(signed.length + signature.length)
envelope.set(signed)
envelope.set(signature, signed.length)
return encodeBase62Bytes(envelope)
}
export async function decodePageToken(
@@ -72,23 +74,25 @@ export async function decodePageToken(
token: string,
input: { query: string; now?: number },
): Promise<PageBoundary> {
const [body, signature, extra] = token.split('.')
if (!body || !signature || extra) invalidPageToken()
const signatureBytes = decodeBase64Url(signature)
const verificationSignature = new Uint8Array(signatureBytes.byteLength)
verificationSignature.set(signatureBytes)
const valid = await crypto.subtle.verify(
'HMAC',
await signingKey(platform),
verificationSignature,
new TextEncoder().encode(body),
)
if (!isBase62(token)) invalidPageToken()
let envelope: Uint8Array
try {
envelope = decodeBase62Bytes(token)
} catch {
invalidPageToken()
}
if (envelope.length < TOKEN_HEADER_BYTES + TOKEN_SIGNATURE_BYTES || envelope[0] !== TOKEN_VERSION) invalidPageToken()
const payloadLength = new DataView(envelope.buffer, envelope.byteOffset, envelope.byteLength).getUint32(1)
const signedLength = TOKEN_HEADER_BYTES + payloadLength
if (envelope.length !== signedLength + TOKEN_SIGNATURE_BYTES) invalidPageToken()
const signed = envelope.slice(0, signedLength)
const verificationSignature = envelope.slice(signedLength)
const valid = await crypto.subtle.verify('HMAC', await signingKey(platform), verificationSignature, signed)
if (!valid) invalidPageToken()
let payload: PageTokenPayload
try {
payload = JSON.parse(new TextDecoder().decode(decodeBase64Url(body))) as PageTokenPayload
payload = JSON.parse(new TextDecoder().decode(signed.slice(TOKEN_HEADER_BYTES))) as PageTokenPayload
} catch {
invalidPageToken()
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageSchema } from '@shared/schemas'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import type { Env } from '../middleware/platform'
import { notFound } from '../usecases/ports'
import { getUserQuota, listQuotaOverview } from '../usecases/quota'
@@ -21,7 +22,7 @@ const currentStoragePlanSchema = z.object({
const effectiveQuotaSchema = z
.object({
orgId: z.string(),
orgId: opaqueIdSchema,
baseQuota: z.number().int(),
entitlementQuota: z.number().int(),
quota: z.number().int(),
@@ -40,7 +41,7 @@ const effectiveQuotaSchema = z
.openapi('EffectiveQuota')
const quotaOverviewItemSchema = effectiveQuotaSchema
.extend({ id: z.string(), orgName: z.string(), orgType: z.string() })
.extend({ id: opaqueIdSchema, orgName: z.string(), orgType: z.string() })
.openapi('QuotaOverviewItem')
const quotaOverviewSchema = pageSchema(quotaOverviewItemSchema, 'QuotaOverview')
+1 -1
View File
@@ -129,7 +129,7 @@ describe('[CF] /r/:token image hosting', () => {
const { app, db } = await buildApp()
const { orgId } = await signUpAndGetIds(app, db)
await insertStorage(db)
const token = `ih_cfext${Date.now()}`
const token = `ihCfext${Date.now()}`
await insertImageHosting(db, orgId, { id: `cf-ihext-${Date.now()}`, token })
const res = await app.request(`/r/${token}.jpg`, { redirect: 'manual' })
+65 -43
View File
@@ -102,9 +102,9 @@ async function getAccessCount(db: Awaited<ReturnType<typeof createTestApp>>['db'
return rows[0]?.access_count ?? 0
}
// ─── ds_ direct share tests ───────────────────────────────────────────────────
// ─── direct share tests ───────────────────────────────────────────────────────
describe('GET /r/:token (ds_ direct shares)', () => {
describe('GET /r/:token (direct shares)', () => {
it('returns 302 with attachment disposition and no-store cache for valid direct share [spec: redirect/direct-share]', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
@@ -132,12 +132,34 @@ describe('GET /r/:token (ds_ direct shares)', () => {
])
})
it('returns 404 for unknown ds_ token [spec: redirect/unknown-ds-token]', async () => {
it('returns 404 for a legacy punctuated token [spec: redirect/unknown-ds-token]', async () => {
const { app } = await createTestApp()
const res = await app.request('/r/ds_unknowntoken', { redirect: 'manual' })
expect(res.status).toBe(404)
})
it('fails closed when a token is owned by both redirect resource tables', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'collision-file', name: 'collision.bin' })
const share = await createShareRepo(db).create({
matterId: 'collision-file',
orgId,
creatorId,
kind: 'direct',
})
await db.run(sql`UPDATE shares SET token = 'CollisionToken' WHERE id = ${share.id}`)
await insertImageHosting(db, orgId, { id: 'collision-image', token: 'CollisionToken' })
const res = await app.request('/r/CollisionToken', { redirect: 'manual' })
expect(res.status).toBe(404)
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
expect(S3Service.prototype.presignInline).not.toHaveBeenCalled()
})
it('returns 404 for landing share token at /r/ [spec: redirect/landing-token-rejected]', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
@@ -293,9 +315,9 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-img1', token: 'ih_testtoken1' })
await insertImageHosting(db, orgId, { id: 'ih-img1', token: 'ihTesttoken1' })
const res = await app.request('/r/ih_testtoken1', { redirect: 'manual' })
const res = await app.request('/r/ihTesttoken1', { redirect: 'manual' })
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe(MOCK_INLINE_URL)
const cc = res.headers.get('cache-control') ?? ''
@@ -332,9 +354,9 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-img2', token: 'ih_exttest1' })
await insertImageHosting(db, orgId, { id: 'ih-img2', token: 'ihExttest1' })
const res = await app.request('/r/ih_exttest1.png', { redirect: 'manual' })
const res = await app.request('/r/ihExttest1.png', { redirect: 'manual' })
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe(MOCK_INLINE_URL)
})
@@ -344,16 +366,16 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-img3', token: 'ih_exttest2' })
await insertImageHosting(db, orgId, { id: 'ih-img3', token: 'ihExttest2' })
const res = await app.request('/r/ih_exttest2.webp', { redirect: 'manual' })
const res = await app.request('/r/ihExttest2.webp', { redirect: 'manual' })
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe(MOCK_INLINE_URL)
})
it('returns 404 for non-existent ih_ token [spec: redirect/unknown-ih-token]', async () => {
const { app } = await createTestApp()
const res = await app.request('/r/ih_doesnotexist', { redirect: 'manual' })
const res = await app.request('/r/ihDoesnotexist', { redirect: 'manual' })
expect(res.status).toBe(404)
})
@@ -362,9 +384,9 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-draft1', token: 'ih_drafttoken', status: 'draft' })
await insertImageHosting(db, orgId, { id: 'ih-draft1', token: 'ihDrafttoken', status: 'draft' })
const res = await app.request('/r/ih_drafttoken', { redirect: 'manual' })
const res = await app.request('/r/ihDrafttoken', { redirect: 'manual' })
expect(res.status).toBe(404)
})
@@ -375,11 +397,11 @@ describe('GET /r/:token (image hosting)', () => {
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, {
id: 'ih-no-storage',
token: 'ih_nostorage',
token: 'ihNostorage',
storageId: 'st-missing-storage',
})
const res = await app.request('/r/ih_nostorage', { redirect: 'manual' })
const res = await app.request('/r/ihNostorage', { redirect: 'manual' })
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Storage not found')
@@ -393,7 +415,7 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-credits', token: 'ih_credits' })
await insertImageHosting(db, orgId, { id: 'ih-credits', token: 'ihCredits' })
const redirectUsecase = await import('../usecases/redirect.js')
vi.spyOn(redirectUsecase, 'resolveImageHostingDownload').mockResolvedValueOnce({
@@ -401,7 +423,7 @@ describe('GET /r/:token (image hosting)', () => {
error: insufficientCredits('Insufficient credits', { metadata: { resource: 'storage_egress' } }),
})
const res = await app.request('/r/ih_credits', { redirect: 'manual' })
const res = await app.request('/r/ihCredits', { redirect: 'manual' })
expect(res.status).toBe(402)
const body = (await res.json()) as {
error: {
@@ -422,10 +444,10 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-cnt1', token: 'ih_counttest1' })
await insertImageHosting(db, orgId, { id: 'ih-cnt1', token: 'ihCounttest1' })
expect(await getAccessCount(db, 'ih-cnt1')).toBe(0)
await app.request('/r/ih_counttest1', { redirect: 'manual' })
await app.request('/r/ihCounttest1', { redirect: 'manual' })
expect(await getAccessCount(db, 'ih-cnt1')).toBe(1)
})
@@ -434,7 +456,7 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-quota-ok', token: 'ih_quotaok' })
await insertImageHosting(db, orgId, { id: 'ih-quota-ok', token: 'ihQuotaok' })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
@@ -442,7 +464,7 @@ describe('GET /r/:token (image hosting)', () => {
WHERE org_id = ${orgId}
`)
const res = await app.request('/r/ih_quotaok', { redirect: 'manual' })
const res = await app.request('/r/ihQuotaok', { redirect: 'manual' })
expect(res.status).toBe(302)
const rows = await db.all<{ trafficUsed: number }>(
@@ -456,7 +478,7 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-sign-fail', token: 'ih_signfail' })
await insertImageHosting(db, orgId, { id: 'ih-sign-fail', token: 'ihSignfail' })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
@@ -465,7 +487,7 @@ describe('GET /r/:token (image hosting)', () => {
`)
vi.mocked(S3Service.prototype.presignInline).mockRejectedValueOnce(new Error('sign failed'))
const res = await app.request('/r/ih_signfail', { redirect: 'manual' })
const res = await app.request('/r/ihSignfail', { redirect: 'manual' })
expect(res.status).toBe(500)
const rows = await db.all<{ trafficUsed: number }>(
@@ -480,7 +502,7 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-quota-repeat', token: 'ih_quotarepeat' })
await insertImageHosting(db, orgId, { id: 'ih-quota-repeat', token: 'ihQuotarepeat' })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
@@ -489,11 +511,11 @@ describe('GET /r/:token (image hosting)', () => {
`)
await setTrafficPlanEntitlement(db, orgId, 1024)
const first = await app.request('/r/ih_quotarepeat', { redirect: 'manual' })
const first = await app.request('/r/ihQuotarepeat', { redirect: 'manual' })
expect(first.status).toBe(302)
expect(first.headers.get('cache-control')).toBe('no-store')
const second = await app.request('/r/ih_quotarepeat', { redirect: 'manual' })
const second = await app.request('/r/ihQuotarepeat', { redirect: 'manual' })
expect(second.status).toBe(422)
const secondBody = (await second.json()) as { error: { message: string; details: Array<{ reason: string }> } }
expect(secondBody.error.message).toBe('Traffic quota exceeded')
@@ -507,9 +529,9 @@ describe('GET /r/:token (image hosting)', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-cnt2', token: 'ih_counttest2', status: 'draft' })
await insertImageHosting(db, orgId, { id: 'ih-cnt2', token: 'ihCounttest2', status: 'draft' })
await app.request('/r/ih_counttest2', { redirect: 'manual' })
await app.request('/r/ihCounttest2', { redirect: 'manual' })
expect(await getAccessCount(db, 'ih-cnt2')).toBe(0)
})
})
@@ -522,10 +544,10 @@ describe('GET /r/:token — referer allowlist enforcement', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-ref1', token: 'ih_reftest1' })
await insertImageHosting(db, orgId, { id: 'ih-ref1', token: 'ihReftest1' })
// No config inserted — no allowlist
const res = await app.request('/r/ih_reftest1', {
const res = await app.request('/r/ihReftest1', {
redirect: 'manual',
headers: { Referer: 'https://anydomain.com/page' },
})
@@ -537,10 +559,10 @@ describe('GET /r/:token — referer allowlist enforcement', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-ref2', token: 'ih_reftest2' })
await insertImageHosting(db, orgId, { id: 'ih-ref2', token: 'ihReftest2' })
await insertImageHostingConfig(db, orgId, { refererAllowlist: ['https://myblog.com'] })
const res = await app.request('/r/ih_reftest2', {
const res = await app.request('/r/ihReftest2', {
redirect: 'manual',
headers: { Referer: 'https://myblog.com/post/1' },
})
@@ -552,10 +574,10 @@ describe('GET /r/:token — referer allowlist enforcement', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-ref3', token: 'ih_reftest3' })
await insertImageHosting(db, orgId, { id: 'ih-ref3', token: 'ihReftest3' })
await insertImageHostingConfig(db, orgId, { refererAllowlist: ['https://myblog.com'] })
const res = await app.request('/r/ih_reftest3', { redirect: 'manual' })
const res = await app.request('/r/ihReftest3', { redirect: 'manual' })
expect(res.status).toBe(302)
})
@@ -564,10 +586,10 @@ describe('GET /r/:token — referer allowlist enforcement', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-ref4', token: 'ih_reftest4' })
await insertImageHosting(db, orgId, { id: 'ih-ref4', token: 'ihReftest4' })
await insertImageHostingConfig(db, orgId, { refererAllowlist: ['https://myblog.com'] })
const res = await app.request('/r/ih_reftest4', {
const res = await app.request('/r/ihReftest4', {
redirect: 'manual',
headers: { Referer: 'https://otherdomain.com/page' },
})
@@ -579,10 +601,10 @@ describe('GET /r/:token — referer allowlist enforcement', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-ref5', token: 'ih_reftest5' })
await insertImageHosting(db, orgId, { id: 'ih-ref5', token: 'ihReftest5' })
await insertImageHostingConfig(db, orgId, { refererAllowlist: ['https://myblog.com'] })
const res = await app.request('/r/ih_reftest5', {
const res = await app.request('/r/ihReftest5', {
redirect: 'manual',
headers: { Referer: 'https://sub.myblog.com/page' },
})
@@ -594,10 +616,10 @@ describe('GET /r/:token — referer allowlist enforcement', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-ref6', token: 'ih_reftest6' })
await insertImageHosting(db, orgId, { id: 'ih-ref6', token: 'ihReftest6' })
await insertImageHostingConfig(db, orgId, { refererAllowlist: ['https://myblog.com'] })
await app.request('/r/ih_reftest6', {
await app.request('/r/ihReftest6', {
redirect: 'manual',
headers: { Referer: 'https://evil.com/page' },
})
@@ -647,9 +669,9 @@ describe('GET /r/:token — two-org isolation', () => {
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
await insertImageHosting(db, orgId, { id: 'ih-iso1', token: 'ih_isolationtest' })
await insertImageHosting(db, orgId, { id: 'ih-iso1', token: 'ihIsolationtest' })
const res = await app.request('/r/ih_isolationtest', { redirect: 'manual' })
const res = await app.request('/r/ihIsolationtest', { redirect: 'manual' })
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe(MOCK_INLINE_URL)
})
@@ -659,7 +681,7 @@ describe('GET /r/:token — two-org isolation', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImageHosting(db, orgId, { id: 'ih-quota', token: 'ih_quotatest' })
await insertImageHosting(db, orgId, { id: 'ih-quota', token: 'ihQuotatest' })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
@@ -668,7 +690,7 @@ describe('GET /r/:token — two-org isolation', () => {
`)
await setTrafficPlanEntitlement(db, orgId, 512)
const res = await app.request('/r/ih_quotatest', { redirect: 'manual' })
const res = await app.request('/r/ihQuotatest', { redirect: 'manual' })
expect(res.status).toBe(422)
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
expect(body.error.message).toBe('Traffic quota exceeded')
+4 -3
View File
@@ -1,7 +1,6 @@
import type { Context } from 'hono'
import { Hono } from 'hono'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { isImageHostingToken } from '../domain/image-hosting'
import { isDownloadFailureStatus, transferAuditActor, transferFailureReason } from '../middleware/audit-transfers'
import type { Env } from '../middleware/platform'
import { notFound } from '../usecases/ports'
@@ -11,6 +10,7 @@ import {
resolveDirectShareDownload,
resolveImageHostingDownload,
resolveRedirectDownloadAuditTarget,
resolveRedirectTokenKind,
} from '../usecases/redirect'
import { recordDownloadFailure, recordDownloadIssued } from '../usecases/transfer-activity'
@@ -99,8 +99,9 @@ app.get('/:token', async (c) => {
const raw = c.req.param('token')
const token = stripExtension(raw)
if (token.startsWith('ds_')) return handleDirectShare(c, token)
if (isImageHostingToken(token)) return handleImageHosting(c, token)
const kind = await resolveRedirectTokenKind(c.get('deps'), token)
if (kind === 'direct_share') return handleDirectShare(c, token)
if (kind === 'image_hosting') return handleImageHosting(c, token)
throw notFound()
})
+7 -8
View File
@@ -6,6 +6,7 @@ import { createShareRepo } from '../adapters/repos/share'
import { auditEvents, shareRecipients, shares } from '../db/schema.js'
import { currentTrafficPeriod } from '../domain/quota.js'
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
import { encodeChildRef } from './share-utils'
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -212,7 +213,7 @@ describe('POST /api/shares', () => {
const body = (await res.json()) as Record<string, unknown>
expect(body.kind).toBe('direct')
expect((body.urls as Record<string, string>).direct).toMatch(/^\/r\/ds_/)
expect((body.urls as Record<string, string>).direct).toMatch(/^\/r\/[A-Za-z0-9]+$/)
expect((body.urls as Record<string, string>).landing).toBeUndefined()
})
@@ -2276,18 +2277,16 @@ describe('Public share routes', () => {
await insertFile(db, orgId, { id: 'out1', name: 'outside.txt' })
const share = await createShareRepo(db).create({ matterId: 'fld3', orgId, creatorId, kind: 'landing' })
const { createHmac } = await import('node:crypto')
const sig = createHmac('sha256', share.token).update('out1').digest('hex').slice(0, 16)
const fakeRef = Buffer.from(`out1.${sig}`).toString('base64url')
const fakeRef = encodeChildRef(share.token, 'out1')
const res = await app.request(`/api/shares/${share.token}/objects/${fakeRef}`, { redirect: 'manual' })
expect(res.status).toBe(404)
})
})
// ─── GET /r/:token — unified redirect for direct shares (ds_) ────────────────
// ─── GET /r/:token — unified redirect for direct shares ─────────────────────
describe('GET /r/:token (ds_ direct shares)', () => {
describe('GET /r/:token (direct shares)', () => {
it('returns 302 redirect for valid direct share', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
@@ -2332,10 +2331,10 @@ describe('Public share routes', () => {
const now = Date.now()
await db.run(sql`
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, views, downloads, status, created_at)
VALUES ('sh-dltrash', 'ds_token-dltrash', 'direct', 'dlx3', ${orgId}, ${creatorId}, 0, 0, 'active', ${now})
VALUES ('sh-dltrash', 'DirectDltrash', 'direct', 'dlx3', ${orgId}, ${creatorId}, 0, 0, 'active', ${now})
`)
const res = await app.request('/r/ds_token-dltrash', { redirect: 'manual' })
const res = await app.request('/r/DirectDltrash', { redirect: 'manual' })
expect(res.status).toBe(410)
})
+22 -21
View File
@@ -4,6 +4,7 @@ import type { Context } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { cursorPageSchema } from '../../shared/schemas'
import { opaqueIdSchema, opaqueTokenSchema } from '../../shared/schemas/identifiers'
import {
createShareRequestSchema,
listSharesQuerySchema,
@@ -50,7 +51,7 @@ const cloudBaseUrl = (c: Context<Env>) => c.get('platform').getEnv('ZPAN_CLOUD_U
// ─── Schemas ─────────────────────────────────────────────────────────────────
const shareViewSchema = z
.object({
token: z.string(),
token: opaqueTokenSchema,
kind: z.string(),
status: z.string(),
expiresAt: z.string().nullable(),
@@ -71,9 +72,9 @@ const shareViewSchema = z
views: z.number().int(),
rootRef: z.string(),
// creator-only fields
id: z.string().optional(),
matterId: z.string().optional(),
orgId: z.string().optional(),
id: opaqueIdSchema.optional(),
matterId: opaqueIdSchema.optional(),
orgId: opaqueIdSchema.optional(),
creatorId: z.string().optional(),
createdAt: z.string().optional(),
recipients: z.array(shareRecipientViewSchema).optional(),
@@ -117,11 +118,11 @@ function toShareViewDTO(dto: ShareViewerDto | ShareCreatorDto): z.infer<typeof s
const shareListItemSchema = z
.object({
id: z.string(),
token: z.string(),
id: opaqueIdSchema,
token: opaqueTokenSchema,
kind: z.string(),
matterId: z.string(),
orgId: z.string(),
matterId: opaqueIdSchema,
orgId: opaqueIdSchema,
creatorId: z.string(),
expiresAt: z.string().nullable(),
downloadLimit: z.number().int().nullable(),
@@ -150,7 +151,7 @@ const shareObjectsSchema = shareObjectsResponseSchema.openapi('ShareObjects')
const createdShareSchema = z
.object({
token: z.string(),
token: opaqueTokenSchema,
kind: z.string(),
urls: z.object({ landing: z.string().optional(), direct: z.string().optional() }),
expiresAt: z.string().nullable(),
@@ -162,16 +163,16 @@ const createdShareSchema = z
// `saved` carries full Matter records; serialized inline (the named `Matter`
// component is owned by the objects router).
const savedMatterSchema = z.object({
id: z.string(),
orgId: z.string(),
alias: z.string(),
id: opaqueIdSchema,
orgId: opaqueIdSchema,
alias: opaqueTokenSchema,
name: z.string(),
type: z.string(),
size: z.number().int().nullable(),
dirtype: z.number().int().nullable(),
parent: z.string(),
object: z.string(),
storageId: z.string(),
storageId: opaqueIdSchema,
status: z.string(),
trashedAt: z.number().int().nullable(),
createdAt: z.string(),
@@ -191,7 +192,7 @@ const saveShareResultSchema = z
const listObjectsQuerySchema = z.object({
parent: z.string().optional(),
pageToken: z.string().min(1).optional(),
pageToken: opaqueTokenSchema.optional(),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
@@ -206,7 +207,7 @@ const viewShareRoute = authRoute(
tags: ['Shares'],
method: 'get',
path: '/{token}',
request: { params: z.object({ token: z.string() }) },
request: { params: z.object({ token: opaqueTokenSchema }) },
responses: {
200: jsonContent(shareViewSchema, 'Share'),
404: errorResponse('Share not found or revoked'),
@@ -223,7 +224,7 @@ const verifyShareRoute = authRoute(
tags: ['Shares'],
method: 'post',
path: '/{token}/sessions',
request: { params: z.object({ token: z.string() }), ...jsonBody(verifyPasswordSchema) },
request: { params: z.object({ token: opaqueTokenSchema }), ...jsonBody(verifyPasswordSchema) },
responses: {
200: jsonContent(z.object({ ok: z.literal(true) }), 'Verified'),
403: errorResponse('Invalid password'),
@@ -240,7 +241,7 @@ const listShareObjectsRoute = authRoute(
tags: ['Shares'],
method: 'get',
path: '/{token}/objects',
request: { params: z.object({ token: z.string() }), query: listObjectsQuerySchema },
request: { params: z.object({ token: opaqueTokenSchema }), query: listObjectsQuerySchema },
responses: {
200: jsonContent(shareObjectsSchema, 'Share objects'),
400: errorResponse('Bad request'),
@@ -259,7 +260,7 @@ const readShareReadmeRoute = authRoute(
tags: ['Shares'],
method: 'get',
path: '/{token}/readme',
request: { params: z.object({ token: z.string() }) },
request: { params: z.object({ token: opaqueTokenSchema }) },
responses: {
200: jsonContent(shareReadmeResponseSchema, 'README.md content'),
400: errorResponse('README.md is not valid UTF-8'),
@@ -447,7 +448,7 @@ const revokeShareRoute = authRoute(
method: 'put',
path: '/{token}/status',
request: {
params: z.object({ token: z.string() }),
params: z.object({ token: opaqueTokenSchema }),
...jsonBody(z.object({ status: z.literal('revoked') })),
},
responses: {
@@ -469,7 +470,7 @@ const putSharePrivacyRoute = authRoute(
method: 'put',
path: '/{token}/privacy',
request: {
params: z.object({ token: z.string() }),
params: z.object({ token: opaqueTokenSchema }),
...jsonBody(sharePrivacySchema),
},
responses: {
@@ -489,7 +490,7 @@ const saveShareRoute = authRoute(
tags: ['Shares'],
method: 'post',
path: '/{token}/objects',
request: { params: z.object({ token: z.string() }), ...jsonBody(saveShareRequestSchema) },
request: { params: z.object({ token: opaqueTokenSchema }), ...jsonBody(saveShareRequestSchema) },
responses: {
201: jsonContent(saveShareResultSchema, 'Saved'),
400: errorResponse('Bad request'),
+6 -5
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { announcementInputSchema, announcementStatusSchema, pageQuerySchema, pageSchema } from '@shared/schemas'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import { type AnnouncementRecord, forbidden, notFound } from '../../usecases/ports'
@@ -16,14 +17,14 @@ import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
const announcementSchema = z
.object({
id: z.string(),
id: opaqueIdSchema,
title: z.string(),
body: z.string(),
status: z.string(),
priority: z.number().int(),
publishedAt: z.string().nullable(),
expiresAt: z.string().nullable(),
createdBy: z.string(),
createdBy: opaqueIdSchema,
createdAt: z.string(),
updatedAt: z.string(),
})
@@ -90,7 +91,7 @@ const getAnnouncementRoute = authRoute(
method: 'get',
path: '/{id}',
middleware: [requireFeature('site_announcements')] as const,
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
200: jsonContent(announcementSchema, 'Announcement'),
404: errorResponse('Announcement not found'),
@@ -107,7 +108,7 @@ const updateAnnouncementRoute = authRoute(
method: 'put',
path: '/{id}',
middleware: [requireFeature('site_announcements')] as const,
request: { params: z.object({ id: z.string() }), ...jsonBody(announcementInputSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(announcementInputSchema) },
responses: {
200: jsonContent(announcementSchema, 'Updated announcement'),
404: errorResponse('Announcement not found'),
@@ -124,7 +125,7 @@ const deleteAnnouncementRoute = authRoute(
method: 'delete',
path: '/{id}',
middleware: [requireFeature('site_announcements')] as const,
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
204: { description: 'Deleted announcement' },
404: errorResponse('Announcement not found'),
+7 -6
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageQuerySchema, pageSchema } from '@shared/schemas'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import type { AdminAuditEventWithOrg } from '../../usecases/ports'
@@ -10,9 +11,9 @@ import { authRoute, errorResponse, jsonContent } from '../openapi'
const auditEventSchema = z
.object({
id: z.string(),
orgId: z.string(),
userId: z.string().nullable(),
id: opaqueIdSchema,
orgId: opaqueIdSchema,
userId: opaqueIdSchema.nullable(),
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
actorRef: z.string().nullable(),
actorIssuer: z.string().nullable(),
@@ -22,7 +23,7 @@ const auditEventSchema = z
targetName: z.string(),
metadata: z.string().nullable(),
createdAt: z.string(),
user: z.object({ id: z.string().nullable(), name: z.string(), image: z.string().nullable() }),
user: z.object({ id: opaqueIdSchema.nullable(), name: z.string(), image: z.string().nullable() }),
orgName: z.string().nullable(),
})
.openapi('AuditEvent')
@@ -36,8 +37,8 @@ function toAuditEventDTO(e: AdminAuditEventWithOrg): AuditEventDTO {
const auditPageSchema = pageSchema(auditEventSchema, 'AuditEventPage')
const listAuditQuerySchema = pageQuerySchema.extend({
orgId: z.string().optional(),
userId: z.string().optional(),
orgId: opaqueIdSchema.optional(),
userId: opaqueIdSchema.optional(),
action: z.string().optional(),
targetType: z.string().optional(),
createdFrom: z.string().datetime().optional(),
+9 -8
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageQuerySchema, pageSchema } from '@shared/schemas'
import { opaqueIdSchema, opaqueTokenSchema } from '@shared/schemas/identifiers'
import type { Env } from '../../middleware/platform'
import { notFound, unauthorized } from '../../usecases/ports'
import {
@@ -15,14 +16,14 @@ import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
// SiteInvitation is already wire-shaped (ISO string timestamps) — no DTO mapper.
const siteInvitationSchema = z
.object({
id: z.string(),
id: opaqueIdSchema,
email: z.string(),
token: z.string(),
invitedBy: z.string(),
token: opaqueTokenSchema,
invitedBy: opaqueIdSchema,
invitedByName: z.string(),
acceptedBy: z.string().nullable(),
acceptedBy: opaqueIdSchema.nullable(),
acceptedAt: z.string().nullable(),
revokedBy: z.string().nullable(),
revokedBy: opaqueIdSchema.nullable(),
revokedAt: z.string().nullable(),
expiresAt: z.string(),
createdAt: z.string(),
@@ -73,7 +74,7 @@ const resendRoute = authRoute(
tags: ['Invitations'],
method: 'post',
path: '/{id}/deliveries',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
200: jsonContent(siteInvitationSchema, 'Resent invitation'),
400: errorResponse('Invitation is no longer pending'),
@@ -90,7 +91,7 @@ const revokeRoute = authRoute(
tags: ['Invitations'],
method: 'delete',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
204: { description: 'Revoked invitation' },
400: errorResponse('Invitation is no longer pending'),
@@ -108,7 +109,7 @@ const getByTokenRoute = authRoute(
tags: ['Invitations'],
method: 'get',
path: '/{token}',
request: { params: z.object({ token: z.string() }) },
request: { params: z.object({ token: opaqueTokenSchema }) },
responses: {
200: jsonContent(siteInvitationSchema, 'Invitation'),
404: errorResponse('Invitation not found'),
@@ -259,14 +259,14 @@ describe('Public Invite Codes API — POST /validate', () => {
expect(res.status).toBe(400)
})
it('returns 400 when code contains lowercase letters', async () => {
it('accepts lowercase letters in the Base62 alphabet', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/site/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'abcd1234' }),
})
expect(res.status).toBe(400)
expect(res.status).toBe(200)
})
it('returns 400 when code is fewer than 8 characters', async () => {
+7 -6
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageQuerySchema, pageSchema } from '@shared/schemas'
import { opaqueIdSchema, opaqueTokenSchema } from '@shared/schemas/identifiers'
import type { Env } from '../../middleware/platform'
import { type InviteCodeRecord, unauthorized } from '../../usecases/ports'
import {
@@ -13,10 +14,10 @@ import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
const inviteCodeSchema = z
.object({
id: z.string(),
code: z.string(),
createdBy: z.string(),
usedBy: z.string().nullable(),
id: opaqueIdSchema,
code: opaqueTokenSchema,
createdBy: opaqueIdSchema,
usedBy: opaqueIdSchema.nullable(),
usedAt: z.string().nullable(),
expiresAt: z.string().nullable(),
createdAt: z.string(),
@@ -45,7 +46,7 @@ const validateSchema = z.object({
code: z
.string()
.length(8)
.regex(/^[0-9A-Z]{8}$/),
.regex(/^[A-Za-z0-9]{8}$/),
})
const listRoute = authRoute(
@@ -85,7 +86,7 @@ const deleteRoute = authRoute(
tags: ['Invite Codes'],
method: 'delete',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
204: { description: 'Deleted invite code' },
409: errorResponse('Cannot delete a used invite code'),
+7 -6
View File
@@ -7,6 +7,7 @@ import {
replaceStorageSchema,
updateStorageEgressBillingSchema,
} from '@shared/schemas'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import type { Env } from '../../middleware/platform'
import { type StorageRecord, storageNotFound } from '../../usecases/ports'
import {
@@ -25,7 +26,7 @@ import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
// Timestamps are the only Date fields; toStorageDTO serializes them.
const storageSchema = z
.object({
id: z.string(),
id: opaqueIdSchema,
provider: z.string(),
bucket: z.string(),
endpoint: z.string(),
@@ -99,7 +100,7 @@ const getStorageRoute = authRoute(
tags: ['Storages'],
method: 'get',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
200: jsonContent(storageSchema, 'Storage'),
404: errorResponse('Storage not found'),
@@ -115,7 +116,7 @@ const replaceStorageRoute = authRoute(
tags: ['Storages'],
method: 'put',
path: '/{id}',
request: { params: z.object({ id: z.string() }), ...jsonBody(replaceStorageSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(replaceStorageSchema) },
responses: {
200: jsonContent(storageSchema, 'Replaced storage'),
402: errorResponse('Feature not available'),
@@ -132,7 +133,7 @@ const patchStorageRoute = authRoute(
tags: ['Storages'],
method: 'patch',
path: '/{id}',
request: { params: z.object({ id: z.string() }), ...jsonBody(patchStorageSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(patchStorageSchema) },
responses: {
200: jsonContent(storageSchema, 'Updated storage'),
402: errorResponse('Feature not available'),
@@ -149,7 +150,7 @@ const updateStorageEgressBillingRoute = authRoute(
tags: ['Storages'],
method: 'put',
path: '/{id}/egress-billing',
request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageEgressBillingSchema) },
request: { params: z.object({ id: opaqueIdSchema }), ...jsonBody(updateStorageEgressBillingSchema) },
responses: {
200: jsonContent(storageSchema, 'Updated storage'),
402: errorResponse('Feature not available'),
@@ -166,7 +167,7 @@ const deleteStorageRoute = authRoute(
tags: ['Storages'],
method: 'delete',
path: '/{id}',
request: { params: z.object({ id: z.string() }) },
request: { params: z.object({ id: opaqueIdSchema }) },
responses: {
204: { description: 'Deleted storage' },
404: errorResponse('Storage not found'),
+2 -1
View File
@@ -1,5 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import type { Env } from '../../middleware/platform'
import { runtimeInfo } from '../../usecases/site/instance-info'
import { getChangelog, resolveInstanceInfo } from '../../usecases/site/system'
@@ -7,7 +8,7 @@ import { authRoute, jsonContent } from '../openapi'
const instanceInfoSchema = z
.object({
id: z.string(),
id: opaqueIdSchema,
name: z.string(),
url: z.string(),
version: z.string(),
+2 -1
View File
@@ -1,5 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import { STORAGE_USAGE_CATEGORIES, STORAGE_USAGE_SORT_FIELDS } from '@shared/storage-usage'
import type { Env } from '../middleware/platform'
import { notFound } from '../usecases/ports'
@@ -24,7 +25,7 @@ const usageSchema = z
.openapi('StorageUsage')
const itemSchema = z.object({
id: z.string(),
id: opaqueIdSchema,
name: z.string(),
path: z.string(),
parentPath: z.string(),
@@ -429,10 +429,10 @@ describe('public redirect cloud traffic reporting', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImage(db, orgId, 'ih-cloud-token', 'ih_cloudtoken')
await insertImage(db, orgId, 'ih-cloud-token', 'ihCloudtoken')
await insertImageConfig(db, orgId)
const res = await app.request('/r/ih_cloudtoken', { redirect: 'manual' })
const res = await app.request('/r/ihCloudtoken', { redirect: 'manual' })
expect(res.status).toBe(302)
await expect(trafficReports(db)).resolves.toMatchObject([
@@ -448,11 +448,11 @@ describe('public redirect cloud traffic reporting', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImage(db, orgId, 'ih-cloud-log-fail', 'ih_cloudlogfail')
await insertImage(db, orgId, 'ih-cloud-log-fail', 'ihCloudlogfail')
await insertImageConfig(db, orgId)
vi.spyOn(db, 'run').mockRejectedValue(new Error('access failed'))
const res = await app.request('/r/ih_cloudlogfail', { redirect: 'manual' })
const res = await app.request('/r/ihCloudlogfail', { redirect: 'manual' })
expect(res.status).toBe(302)
expect(consoleError).toHaveBeenCalled()
@@ -466,10 +466,10 @@ describe('public redirect cloud traffic reporting', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImage(db, orgId, 'ih-cloud-presign-fail', 'ih_cloudpresignfail')
await insertImage(db, orgId, 'ih-cloud-presign-fail', 'ihCloudpresignfail')
await insertImageConfig(db, orgId)
const res = await app.request('/r/ih_cloudpresignfail', { redirect: 'manual' })
const res = await app.request('/r/ihCloudpresignfail', { redirect: 'manual' })
expect(res.status).toBe(500)
expect(fetch).not.toHaveBeenCalled()
@@ -483,7 +483,7 @@ describe('public redirect cloud traffic reporting', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImage(db, orgId, 'ih-cloud-domain', 'ih_clouddomain', 'blog/domain.png')
await insertImage(db, orgId, 'ih-cloud-domain', 'ihClouddomain', 'blog/domain.png')
await insertImageConfig(db, orgId, 'img.example.com')
const res = await app.request('https://img.example.com/blog/domain.png', {
@@ -507,7 +507,7 @@ describe('public redirect cloud traffic reporting', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImage(db, orgId, 'ih-cloud-domain-blocked', 'ih_clouddomainblocked', 'blog/domain-blocked.png')
await insertImage(db, orgId, 'ih-cloud-domain-blocked', 'ihClouddomainblocked', 'blog/domain-blocked.png')
await insertImageConfig(db, orgId, 'img-blocked.example.com')
await setTrafficQuota(db, orgId)
@@ -542,7 +542,7 @@ describe('public redirect cloud traffic reporting', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImage(db, orgId, 'ih-cloud-domain-confirm-fail', 'ih_clouddomainconfirmfail', 'blog/confirm-fail.png')
await insertImage(db, orgId, 'ih-cloud-domain-confirm-fail', 'ihClouddomainconfirmfail', 'blog/confirm-fail.png')
await insertImageConfig(db, orgId, 'img-confirm-fail.example.com')
await setTrafficQuota(db, orgId)
vi.spyOn(deps.cloudTrafficReports, 'markIssued').mockRejectedValueOnce(new Error('confirm failed'))
@@ -574,7 +574,7 @@ describe('public redirect cloud traffic reporting', () => {
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertImage(db, orgId, 'ih-cloud-domain-log-fail', 'ih_clouddomainlogfail', 'blog/domain-log-fail.png')
await insertImage(db, orgId, 'ih-cloud-domain-log-fail', 'ihClouddomainlogfail', 'blog/domain-log-fail.png')
await insertImageConfig(db, orgId, 'img.example.com')
vi.spyOn(db, 'run').mockRejectedValue(new Error('access failed'))
+27 -21
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { pageQuerySchema, pageSchema } from '@shared/schemas'
import { opaqueIdSchema, opaqueTokenSchema } from '@shared/schemas/identifiers'
import type { Env } from '../middleware/platform'
import {
type AuditEventWithUser,
@@ -41,7 +42,7 @@ import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
const inviteLinkInfoSchema = z
.object({
organizationId: z.string(),
organizationId: opaqueIdSchema,
organizationName: z.string(),
role: z.string(),
expiresAt: z.string().nullable(),
@@ -52,11 +53,13 @@ function toInviteLinkInfoDTO(i: InviteLinkInfo): z.infer<typeof inviteLinkInfoSc
return { ...i, expiresAt: i.expiresAt ? i.expiresAt.toISOString() : null }
}
const inviteLinkCreatedSchema = z.object({ token: z.string(), expiresAt: z.string() }).openapi('TeamInviteLinkCreated')
const inviteLinkCreatedSchema = z
.object({ token: opaqueTokenSchema, expiresAt: z.string() })
.openapi('TeamInviteLinkCreated')
const pendingInvitationSchema = z
.object({
id: z.string(),
id: opaqueIdSchema,
email: z.string(),
role: z.string(),
expiresAt: z.string().nullable(),
@@ -72,9 +75,9 @@ const pendingInvitationListSchema = pageSchema(pendingInvitationSchema, 'TeamInv
const activityEventSchema = z
.object({
id: z.string(),
orgId: z.string(),
userId: z.string().nullable(),
id: opaqueIdSchema,
orgId: opaqueIdSchema,
userId: opaqueIdSchema.nullable(),
actorType: z.enum(['user', 'api_key', 'oauth', 'agent', 'anonymous', 'system', 'downloader', 'task-upload']),
actorRef: z.string().nullable(),
actorIssuer: z.string().nullable(),
@@ -84,7 +87,7 @@ const activityEventSchema = z
targetName: z.string(),
metadata: z.string().nullable(),
createdAt: z.string(),
user: z.object({ id: z.string().nullable(), name: z.string(), image: z.string().nullable() }),
user: z.object({ id: opaqueIdSchema.nullable(), name: z.string(), image: z.string().nullable() }),
})
.openapi('AuditEvent')
@@ -96,7 +99,7 @@ const activityPageSchema = pageSchema(activityEventSchema, 'ActivityPage')
const teamSummarySchema = z
.object({
id: z.string(),
id: opaqueIdSchema,
name: z.string(),
slug: z.string(),
logo: z.string().nullable(),
@@ -115,7 +118,7 @@ const createLinkSchema = z.object({
expiresIn: z.number().int().min(1).optional(),
})
const joinSchema = z.object({ token: z.string().min(1) })
const joinSchema = z.object({ token: opaqueTokenSchema })
const grantEntitlementSchema = z.object({
resourceType: z.literal('storage'),
@@ -155,7 +158,7 @@ const inviteLinkInfoRoute = authRoute(
tags: ['Teams'],
method: 'get',
path: '/invite-links/{token}',
request: { params: z.object({ token: z.string() }) },
request: { params: z.object({ token: opaqueTokenSchema }) },
responses: {
200: jsonContent(inviteLinkInfoSchema, 'Invite link info'),
404: errorResponse('Invalid or expired invite link'),
@@ -178,7 +181,7 @@ const createInviteLinkRoute = authRoute(
tags: ['Teams'],
method: 'post',
path: '/{teamId}/invite-links',
request: { params: z.object({ teamId: z.string() }), ...jsonBody(createLinkSchema) },
request: { params: z.object({ teamId: opaqueIdSchema }), ...jsonBody(createLinkSchema) },
responses: {
201: jsonContent(inviteLinkCreatedSchema, 'Created invite link'),
403: errorResponse('Forbidden'),
@@ -194,7 +197,7 @@ const listInvitationsRoute = authRoute(
tags: ['Teams'],
method: 'get',
path: '/{teamId}/invitations',
request: { params: z.object({ teamId: z.string() }) },
request: { params: z.object({ teamId: opaqueIdSchema }) },
responses: {
200: jsonContent(pendingInvitationListSchema, 'Pending invitations'),
403: errorResponse('Forbidden'),
@@ -210,7 +213,7 @@ const joinTeamRoute = authRoute(
tags: ['Teams'],
method: 'post',
path: '/{teamId}/members',
request: { params: z.object({ teamId: z.string() }), ...jsonBody(joinSchema) },
request: { params: z.object({ teamId: opaqueIdSchema }), ...jsonBody(joinSchema) },
responses: {
200: jsonContent(z.object({ ok: z.literal(true) }), 'Joined'),
404: errorResponse('Invalid invite link'),
@@ -229,7 +232,7 @@ const activityRoute = authRoute(
method: 'get',
path: '/{teamId}/activity',
request: {
params: z.object({ teamId: z.string() }),
params: z.object({ teamId: opaqueIdSchema }),
query: pageQuerySchema,
},
responses: {
@@ -249,7 +252,7 @@ const setLogoRoute = authRoute(
path: '/{teamId}/logo',
// Body is multipart/form-data (a `file` field); parsed directly in the handler
// rather than via a request schema (the form validator conflicts with formData()).
request: { params: z.object({ teamId: z.string() }) },
request: { params: z.object({ teamId: opaqueIdSchema }) },
responses: {
200: jsonContent(z.object({ url: z.string() }), 'Logo URL'),
400: errorResponse('Bad request'),
@@ -269,7 +272,7 @@ const deleteLogoRoute = authRoute(
tags: ['Teams'],
method: 'delete',
path: '/{teamId}/logo',
request: { params: z.object({ teamId: z.string() }) },
request: { params: z.object({ teamId: opaqueIdSchema }) },
responses: {
204: { description: 'Deleted' },
403: errorResponse('Forbidden'),
@@ -370,7 +373,7 @@ const getTeamRoute = authRoute(
tags: ['Teams'],
method: 'get',
path: '/{teamId}',
request: { params: z.object({ teamId: z.string() }) },
request: { params: z.object({ teamId: opaqueIdSchema }) },
responses: {
200: jsonContent(teamSummarySchema, 'Team'),
404: errorResponse('Team not found'),
@@ -386,7 +389,7 @@ const listEntitlementsRoute = authRoute(
tags: ['Teams'],
method: 'get',
path: '/{teamId}/entitlements',
request: { params: z.object({ teamId: z.string() }) },
request: { params: z.object({ teamId: opaqueIdSchema }) },
responses: {
200: jsonContent(entitlementListSchema, 'Entitlements'),
400: errorResponse('Bad request'),
@@ -403,7 +406,7 @@ const grantEntitlementRoute = authRoute(
tags: ['Teams'],
method: 'post',
path: '/{teamId}/entitlements',
request: { params: z.object({ teamId: z.string() }), ...jsonBody(grantEntitlementSchema) },
request: { params: z.object({ teamId: opaqueIdSchema }), ...jsonBody(grantEntitlementSchema) },
responses: {
201: jsonContent(entitlementResultSchema, 'Granted entitlement'),
400: errorResponse('Bad request'),
@@ -420,7 +423,10 @@ const updateEntitlementRoute = authRoute(
tags: ['Teams'],
method: 'patch',
path: '/{teamId}/entitlements/{eid}',
request: { params: z.object({ teamId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) },
request: {
params: z.object({ teamId: opaqueIdSchema, eid: opaqueIdSchema }),
...jsonBody(updateEntitlementSchema),
},
responses: {
200: jsonContent(entitlementResultSchema, 'Updated entitlement'),
400: errorResponse('Bad request'),
@@ -437,7 +443,7 @@ const revokeEntitlementRoute = authRoute(
tags: ['Teams'],
method: 'delete',
path: '/{teamId}/entitlements/{eid}',
request: { params: z.object({ teamId: z.string(), eid: z.string() }) },
request: { params: z.object({ teamId: opaqueIdSchema, eid: opaqueIdSchema }) },
responses: {
204: { description: 'Revoked entitlement' },
400: errorResponse('Bad request'),
+6 -5
View File
@@ -1,6 +1,7 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { cursorPageQuerySchema, cursorPageSchema, restoreObjectSchema } from '@shared/schemas'
import { opaqueIdSchema, opaqueTokenSchema } from '@shared/schemas/identifiers'
import type { Env } from '../middleware/platform'
import { deleteObject, getTrashObject, listTrashedObjects, restoreObject } from '../usecases/object'
import { badRequest, type Matter, notFound } from '../usecases/ports'
@@ -11,16 +12,16 @@ import { decodeOptionalPageToken, encodeNextPageToken, pageQueryFingerprint, tra
// grouping/view of `objects`, not a separate resource.
const matterSchema = z
.object({
id: z.string(),
orgId: z.string(),
alias: z.string(),
id: opaqueIdSchema,
orgId: opaqueIdSchema,
alias: opaqueTokenSchema,
name: z.string(),
type: z.string(),
size: z.number().int().nullable(),
dirtype: z.number().int().nullable(),
parent: z.string(),
object: z.string(),
storageId: z.string(),
storageId: opaqueIdSchema,
status: z.string(),
trashedAt: z.number().int().nullable(),
createdAt: z.string(),
@@ -50,7 +51,7 @@ function toMatterDTO(m: Matter): MatterDTO {
}
const trashPageSchema = cursorPageSchema(matterSchema, 'TrashObjectPage')
const idParam = z.object({ id: z.string() })
const idParam = z.object({ id: opaqueIdSchema })
const listTrashRoute = authRoute(
{ scopes: [AuthorizationScope.OBJECTS_READ], minTeamRole: 'viewer' },
+9 -5
View File
@@ -1,5 +1,6 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import { AuthorizationScope } from '@shared/authorization'
import { opaqueIdSchema } from '@shared/schemas/identifiers'
import { publicProfileSchema } from '@shared/schemas/profile'
import type { Env } from '../middleware/platform'
import {
@@ -130,7 +131,7 @@ const getUserQuotaRoute = authRoute(
tags: ['Users'],
method: 'get',
path: '/{userId}/quota',
request: { params: z.object({ userId: z.string() }) },
request: { params: z.object({ userId: opaqueIdSchema }) },
responses: { 200: jsonContent(userQuotaSchema, 'User quota') },
},
)
@@ -143,7 +144,7 @@ const listUserEntitlementsRoute = authRoute(
tags: ['Users'],
method: 'get',
path: '/{userId}/entitlements',
request: { params: z.object({ userId: z.string() }) },
request: { params: z.object({ userId: opaqueIdSchema }) },
responses: {
200: jsonContent(entitlementListSchema, 'Entitlements'),
400: errorResponse('Bad request'),
@@ -160,7 +161,7 @@ const grantUserEntitlementRoute = authRoute(
tags: ['Users'],
method: 'post',
path: '/{userId}/entitlements',
request: { params: z.object({ userId: z.string() }), ...jsonBody(grantEntitlementSchema) },
request: { params: z.object({ userId: opaqueIdSchema }), ...jsonBody(grantEntitlementSchema) },
responses: {
201: jsonContent(entitlementResultSchema, 'Granted'),
400: errorResponse('Bad request'),
@@ -177,7 +178,10 @@ const updateUserEntitlementRoute = authRoute(
tags: ['Users'],
method: 'patch',
path: '/{userId}/entitlements/{eid}',
request: { params: z.object({ userId: z.string(), eid: z.string() }), ...jsonBody(updateEntitlementSchema) },
request: {
params: z.object({ userId: opaqueIdSchema, eid: opaqueIdSchema }),
...jsonBody(updateEntitlementSchema),
},
responses: {
200: jsonContent(entitlementResultSchema, 'Updated'),
400: errorResponse('Bad request'),
@@ -194,7 +198,7 @@ const revokeUserEntitlementRoute = authRoute(
tags: ['Users'],
method: 'delete',
path: '/{userId}/entitlements/{eid}',
request: { params: z.object({ userId: z.string(), eid: z.string() }) },
request: { params: z.object({ userId: opaqueIdSchema, eid: opaqueIdSchema }) },
responses: {
204: { description: 'Revoked' },
400: errorResponse('Bad request'),
+5 -5
View File
@@ -13,20 +13,20 @@ describe('buildObjectKey', () => {
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'))
const result = buildObjectKey(baseVars)
// Template: $ORG_ID/$UID/$NOW_DATE/$RAND_16KEY$RAW_EXT
expect(result).toMatch(/^org456\/user123\/20260315\/.{16}\.jpg$/)
// 17 Base62 characters preserve the entropy of the previous 16-character Nano ID.
expect(result).toMatch(/^org456\/user123\/20260315\/[A-Za-z0-9]{17}\.jpg$/)
vi.useRealTimers()
})
it('includes a 16-char random key', () => {
it('includes a 17-char Base62 random key', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'))
const result = buildObjectKey(baseVars)
const parts = result.split('/')
const filename = parts[3] // RAND_16KEY + ext
expect(filename.replace('.jpg', '')).toHaveLength(16)
expect(filename.replace('.jpg', '')).toMatch(/^[A-Za-z0-9]{17}$/)
vi.useRealTimers()
})
@@ -36,7 +36,7 @@ describe('buildObjectKey', () => {
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'))
const result = buildObjectKey({ ...baseVars, rawExt: '' })
expect(result).toMatch(/^org456\/user123\/20260315\/.{16}$/)
expect(result).toMatch(/^org456\/user123\/20260315\/[A-Za-z0-9]{17}$/)
vi.useRealTimers()
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { nanoid } from 'nanoid'
import { generateToken } from '@shared/ids'
export interface TemplateVars {
uid: string
@@ -18,5 +18,5 @@ export function buildObjectKey(vars: TemplateVars): string {
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${vars.orgId}/${vars.uid}/${year}${month}${day}/${nanoid(16)}${vars.rawExt}`
return `${vars.orgId}/${vars.uid}/${year}${month}${day}/${generateToken(17)}${vars.rawExt}`
}
+32 -1
View File
@@ -60,7 +60,7 @@ describe('global OpenAPI document', () => {
schema: {
properties: {
registration_client_uri: { type: 'string', format: 'uri' },
registration_access_token: { type: 'string' },
registration_access_token: { type: 'string', pattern: '^[A-Za-z0-9]{43}$' },
},
required: expect.arrayContaining(['registration_client_uri', 'registration_access_token']),
},
@@ -71,6 +71,37 @@ describe('global OpenAPI document', () => {
})
})
it('publishes Base62 contracts for ZPan-owned IDs and public tokens', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/openapi.json')
const doc = (await res.json()) as {
paths: Record<string, Record<string, { parameters?: Array<{ name?: string; schema?: { pattern?: string } }> }>>
components?: { schemas?: Record<string, { properties?: Record<string, { pattern?: string }> }> }
}
const parameterPattern = (path: string, method: string, name: string) =>
doc.paths[path]?.[method]?.parameters?.find((parameter) => parameter.name === name)?.schema?.pattern
const objectId = doc.paths['/api/objects/{id}']?.get?.parameters?.find(({ name }) => name === 'id')
const shareToken = doc.paths['/api/shares/{token}']?.get?.parameters?.find(({ name }) => name === 'token')
expect(objectId?.schema?.pattern).toBe('^[A-Za-z0-9]+$')
expect(shareToken?.schema?.pattern).toBe('^[A-Za-z0-9]+$')
expect(parameterPattern('/api/teams/{teamId}', 'get', 'teamId')).toBe('^[A-Za-z0-9]+$')
expect(parameterPattern('/api/users/{userId}/entitlements/{eid}', 'delete', 'userId')).toBe('^[A-Za-z0-9]+$')
expect(parameterPattern('/api/users/{userId}/entitlements/{eid}', 'delete', 'eid')).toBe('^[A-Za-z0-9]+$')
expect(parameterPattern('/api/site/announcements/{id}', 'get', 'id')).toBe('^[A-Za-z0-9]+$')
expect(parameterPattern('/api/downloads/tasks', 'get', 'pageToken')).toBe('^[A-Za-z0-9]+$')
expect(parameterPattern('/api/image-hosting/images', 'get', 'pageToken')).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.Matter?.properties?.id?.pattern).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.TeamSummary?.properties?.id?.pattern).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.Announcement?.properties?.createdBy?.pattern).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.QuotaEntitlement?.properties?.orgId?.pattern).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.DownloadTask?.properties?.id?.pattern).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.DownloadTaskPage?.properties?.nextPageToken?.pattern).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.DownloadTaskListPage?.properties?.nextPageToken?.pattern).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.ImageHostingList?.properties?.nextPageToken?.pattern).toBe('^[A-Za-z0-9]+$')
expect(doc.components?.schemas?.ShareObjects?.properties?.nextPageToken?.pattern).toBe('^[A-Za-z0-9]+$')
})
it('serves the Scalar reference UI at /api/docs pointing at the spec', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
const res = await app.request('/api/docs')
+5 -2
View File
@@ -93,7 +93,7 @@ describe('admin stats backfill', () => {
created_at INTEGER NOT NULL, creator_id TEXT
);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY, org_id TEXT NOT NULL, user_id TEXT, actor_type TEXT, actor_ref TEXT,
id TEXT PRIMARY KEY, event_key TEXT UNIQUE, org_id TEXT NOT NULL, user_id TEXT, actor_type TEXT, actor_ref TEXT,
action TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT, target_name TEXT NOT NULL,
metadata TEXT, created_at INTEGER NOT NULL
);
@@ -162,7 +162,9 @@ describe('admin stats backfill', () => {
('m2', 'o2', 'u2', ${historyStartMs + 1000});
INSERT INTO matters VALUES ('f1', 512, 0);
INSERT INTO shares VALUES ('s1', 'landing', 'f1', 'o1', 'active', NULL, 10, 0, 1, ${eventSec}, 'u1');
INSERT INTO audit_events VALUES
INSERT INTO audit_events (
id, org_id, user_id, actor_type, actor_ref, action, target_type, target_id, target_name, metadata, created_at
) VALUES
('audit:statistics_source_initialized:v3-authoritative-sources', '', NULL, 'system', 'statistics-integrity', 'statistics_source_initialized', 'statistics', 'v3-authoritative-sources', 'statistics source', '{"schemaVersion":3}', ${Math.floor(historyStartMs / 1000)}),
('upload-1', 'o1', 'u1', NULL, NULL, 'upload_confirm', 'file', 'f1', 'file.bin',
'{"bytes":512,"source":"upload","status":"success"}', ${eventSec}),
@@ -182,6 +184,7 @@ describe('admin stats backfill', () => {
('legacy-task-completed', 'o1', NULL, 'system', 'legacy-download-task-worker', 'download_task_completed', 'remote_download', 't1', 'task',
'{"category":"video","outcome":"completed","bytes":512}', ${eventSec}),
('legacy-cloud-customer', 'o1', 'cloud-customer-1', 'user', NULL, 'quota_order_increase', 'quota', 'o1', 'o1', NULL, ${eventSec});
UPDATE audit_events SET event_key = id WHERE id = 'audit:statistics_source_initialized:v3-authoritative-sources';
INSERT INTO cloud_traffic_reports (
id, org_id, period, source, source_id, event_id, bytes, storage_id, unit_bytes, credits_per_unit,
status, error, attempt_count, next_retry_at, issued_at, created_at, updated_at
+7
View File
@@ -500,6 +500,7 @@ const APP_SCHEMA_SQL = `
CREATE UNIQUE INDEX IF NOT EXISTS team_invite_links_token_unique ON team_invite_links(token);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
event_key TEXT UNIQUE,
org_id TEXT NOT NULL,
user_id TEXT,
action TEXT NOT NULL,
@@ -574,6 +575,12 @@ const APP_SCHEMA_SQL = `
);
CREATE INDEX IF NOT EXISTS shares_creator_status_created_idx ON shares(creator_id, status, created_at);
CREATE INDEX IF NOT EXISTS shares_creator_private_created_idx ON shares(creator_id, private, created_at);
CREATE TABLE IF NOT EXISTS redirect_token_registry (
token TEXT PRIMARY KEY,
kind TEXT NOT NULL,
resource_id TEXT NOT NULL,
UNIQUE (kind, resource_id)
);
CREATE TABLE IF NOT EXISTS share_recipients (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
+4 -4
View File
@@ -1,3 +1,4 @@
import { generateId, generateToken } from '@shared/ids'
import type {
CreateDownloaderInput,
CreateDownloadTaskInput,
@@ -17,7 +18,6 @@ import type {
DownloadTaskRuntime,
DownloadTaskTimelineItem,
} from '@shared/types'
import { nanoid } from 'nanoid'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
import { parseDownloadTaskEvents } from '../../domain/download-task-events'
import { hasFeature } from '../../domain/licensing'
@@ -159,8 +159,8 @@ async function prepareDownloaderRegistration(
userId: string,
now: Date,
) {
const id = nanoid()
const jti = nanoid()
const id = generateId()
const jti = generateToken(22)
const token = await deps.downloadTokens.signDownloadToken(platform, {
v: 1,
typ: 'downloader',
@@ -317,7 +317,7 @@ export async function createDownloadTask(
folderPath: input.targetFolder,
})
const now = new Date()
const id = nanoid()
const id = generateId()
await deps.downloadTasks.insert({
id,
orgId,
+3 -3
View File
@@ -1,5 +1,5 @@
import { generateToken } from '@shared/ids'
import type { PutIhostConfigInput } from '@shared/schemas'
import { nanoid } from 'nanoid'
import { hasFeature } from '../../domain/licensing'
import {
AppError,
@@ -168,8 +168,8 @@ export async function putImageHostingConfig(
verificationToken:
newDomain && provider?.settings.provider === 'manual'
? newDomain === oldDomain
? (existing?.verificationToken ?? nanoid(32))
: nanoid(32)
? (existing?.verificationToken ?? generateToken(33))
: generateToken(33)
: null,
} as const

Some files were not shown because too many files have changed in this diff Show More