Allow backdating createdAt/updatedAt on unscoped Stack.create() - #214
Allow backdating createdAt/updatedAt on unscoped Stack.create()#214cuibonobo wants to merge 3 commits into
Conversation
Closes #203. Stack.create() stamped createdAt/updatedAt from new Date() unconditionally, so importing an existing archive collapsed every record to the import moment. Since a full-trust caller already picks a record's creation *position* via a client-minted id, letting createdAt agree with it closes an inconsistency rather than opening a new hole. - UnscopedCreateRecordOptions adds createdAt/updatedAt to Stack.create() only. ScopedStack.create() never accepts them, even past the type system (a grantee could otherwise forge a sort position the same way a raw id could), and the wire format is unchanged. - Omit id and it's derived from createdAt's timestamp, so the two agree by construction; supply both and they're checked against each other with the same idTimestampSkewMs tolerance ScopedStack's grantee check uses. - updatedAt defaults to createdAt, not the actual current time, so a plain import doesn't fabricate a fake edit. - Added generateIdForTimestamp() to id.ts: deriving an id from an explicit createdAt must not go through generateId()'s monotonic "never sort before a live id already minted this process" floor, or a backdated import would silently get clamped forward to "now" the moment any live create() has run first. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKowhhMr6TnFEKsgaYtNYm
🦋 Changeset detectedLatest commit: f5ef1e9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 8 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Hardening follow-up to the createdAt/updatedAt backdating options. The
privilege boundary those options draw is sound — ScopedStack.create()
refuses both fields before doing anything else, and
generateIdForTimestamp() neither reads nor advances generateId()'s
monotonic floor, so an import can't perturb live ID generation. What was
missing was input validation on the full-trust path, where three inputs a
real import can produce were accepted and stored:
- An Invalid Date (what a malformed source row parses to) has a NaN
getTime(), and every comparison against NaN is false — so it didn't slip
past the updatedAt/createdAt ordering check and the id/createdAt skew
check, it switched them off. The record then persisted with an
epoch-zero ID and a createdAt whose toISOString() throws RangeError in
serializeRecord(), leaving that record and any wire response containing
it permanently unreadable.
- A pre-epoch createdAt surfaced as a bare RangeError ("Not defined for
negative numbers!") thrown from inside the ID encoder, naming neither
the field nor the record — and was accepted outright when an explicit
`id` skipped ID derivation.
- A createdAt past 3084-12-12 overflowed the 9-character timestamp prefix,
minting a 13-character ID that isValidIdFormat() rejects — the library
producing an ID it refuses on the way back in. Year-9999 sentinels are
ordinary in imported data.
Both fields are now checked for validity and for the range a record ID's
timestamp prefix can encode, as a StackValidationError naming the field,
whether or not an `id` is supplied. generateIdForTimestamp() carries the
same bound itself, since unlike generateId() it encodes a caller-supplied
timestamp rather than Date.now().
Also:
- The ordering check now compares against the effective createdAt, so an
updatedAt supplied on its own is caught too. Previously it required both
fields, letting { updatedAt: <past> } store a record modified before it
was created.
- Caller Dates are copied rather than stored by reference. An import loop
that advances and reuses one Date across rows would otherwise retro-edit
every record it had already written, with no version bump and no change
event.
Spec and changeset document the new rules, plus a note that backdated
records fall behind an updatedAt sync cursor by construction.
|
Pushed The privilege boundary holds. No escalation path was found. Three things are right and worth naming so they don't get "simplified" later:
What the follow-up commit fixes. All five are reachable only by a full-trust caller, so none is an escalation — but "full trust" here means an import script reading a third-party archive, so the data is untrusted even when the caller isn't.
Both clock fields are now validated for validity and encodable range as a Two judgement calls left open for you:
13 regression tests added. Full suite green: Generated by Claude Code |
…wire Follow-up to the createdAt/updatedAt backdating options. As scoped, that change only worked for a caller with direct in-process access to an unscoped Stack — a server-hosted deployment had no path at all, since ScopedStack.create() (which every request through a server goes through) refused both fields unconditionally, and the wire spec separately told servers to always ignore them. createdAt/updatedAt now follow the same owner-acting-alone tier already used for hard delete, commitMigration(), and includeUnlisted: - ScopedStack.create() accepts both fields only when the requester is the stack owner, undelegated, authenticated as themselves. A grantee, or a delegated app acting for the owner (either direction — delegating to the owner, or the owner delegating to someone else), is still refused with StackPermissionError. - The existing id-vs-current-time skew check on ScopedStack.create() is skipped when createdAt is also supplied (only reachable by the owner), since Stack.create() below checks the id against createdAt instead — the "vs. now" check is for a live write, and a backdated one deliberately isn't. - No new wire-side code: adapter-api already serializes the full record (Dates included) via JSON.stringify, so a client always sent these fields — what changes is server-side handling. A server built on ScopedStack inherits the owner-only enforcement automatically, the same way it already inherits entityId/principalId assignment. Renamed UnscopedCreateRecordOptions to BackdatableCreateRecordOptions, since ScopedStack.create() now accepts it too (conditionally). Updated docs/spec/data-model.md and wire-format.md, and the changeset. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RKowhhMr6TnFEKsgaYtNYm
Summary
Closes #203.
Stack.create()stampedcreatedAt/updatedAtfromnew Date()unconditionally, so importing an existing archive (dated posts, migrated content) collapsed every record to the import moment. Since a full-trust caller already picks a record's creation position via a client-mintedid, lettingcreatedAtagree with it closes an inconsistency rather than opening a new capability — the framing the issue itself proposes.BackdatableCreateRecordOptionsaddscreatedAt?: Date/updatedAt?: Date.Stack.create()accepts both unconditionally (full trust — an embedded app, or a server's own code).ScopedStack.create()accepts both only from the stack owner acting alone (undelegated, authenticated as themselves) — the same tier that already gates hard delete,commitMigration(), andincludeUnlisted. A grantee, or a delegated app acting for the owner (either direction), is refused withStackPermissionError.ScopedStackinherits this automatically: an owner-authenticatedPOST /recordsmay carry both fields, anyone else's has them ignored, exactly asentityId/principalIdalready are. No client-side (adapter-api) code changes were needed — it already serializes the full record, Dates included, viaJSON.stringify; the old spec text was purely a server-side instruction to discard the fields, which is what's changing.idand it's derived fromcreatedAt's timestamp, so the two agree by construction. Supply both and they're checked against each other using the sameidTimestampSkewMstolerance the ordinaryid-vs-current-time check already uses — disagreement throwsStackValidationErrorrather than silently diverging. An owner's plainid-only create throughScopedStackis unaffected: still checked against the current time, not againstcreatedAt.updatedAtdefaults tocreatedAt(not the actual current time), so a plain import doesn't fabricate a fake edit or pollute version history.Invalid Date'sNaNtimestamp would otherwise silently switch off the ordering/skew checks instead of failing them; acreatedAtoutside1970-01-01…3084-12-12has no ID that could agree with it), and Dates are copied on the way in so a reused mutableDateacross an import loop can't retro-edit already-written records.Bug caught while implementing the first pass: deriving an id straight from
createdAtviagenerateId(timestamp)hits its monotonic "never sort before a live id already minted this process" floor — a backdated import would get silently clamped forward to "now" the moment any livecreate()had already run in that process. AddedgenerateIdForTimestamp()inid.ts, which mints from an explicit timestamp without consulting or advancing that floor.Spec
docs/spec/data-model.md§ Record IDs — new "Backdating on import" subsection.docs/spec/wire-format.md§ Records — thecreatedAt/updatedAtserver-assignment rule now carries the owner-authenticated exception, mirroring howappIdis already called out as a deliberate exception.Verification
All green across every workspace package (962 tests in
@haverstack/core).Notes for reviewers
ownerActingAlonecarve-out onScopedStack.create()closes that gap using an existing, precedented trust tier rather than inventing a new one.createdAt/updatedAtfor non-ownerScopedStack.create()callers (matching the wire behavior forentityId/principalId) instead of throwing. Went with throwing, consistent with this codebase's existing philosophy for scoped writes ("refused rather than silently ignored, so an app never believes it published something it didn't" — seemayGrantAccess()'s doc comment).UnscopedCreateRecordOptions→BackdatableCreateRecordOptionssinceScopedStack.create()now accepts it too (conditionally).