Skip to content

feat: --ttl on publish, and a page whose deadline is no longer a secret - #2

Merged
ProJedi1234 merged 2 commits into
mainfrom
feat/page-ttl
Aug 5, 2026
Merged

feat: --ttl on publish, and a page whose deadline is no longer a secret#2
ProJedi1234 merged 2 commits into
mainfrom
feat/page-ttl

Conversation

@ProJedi1234

@ProJedi1234 ProJedi1234 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

The server has had per-page expiry since stele-pages#17 and this client knew nothing about it. PageLocation decoded slug and url and dropped the expires the server sent — so stele publish printed a URL for a page that would stop being served in a matter of days and said nothing about it.

The ephemeral default is right: a page that outlives its purpose fails quietly and forever, while one that expires early fails loudly to somebody who can republish it. But that only works if the caller is told.

What this adds

--ttl <days|never> on publish, and the deadline printed under the URL.

$ stele publish report.html
https://stele.example.com/quiet-cedar-otter
expires 2026-08-11 10:31

$ stele publish report.html --slug q3-report --ttl never
https://stele.example.com/q3-report
kept until deleted

stdout stays the bare URL — url=$(stele publish page.html) still works. The deadline is on stderr.

Two calls worth reviewing

Both diverge from the sibling --expires-in, deliberately.

A bare number is accepted here. --expires-in refuses one because its unit is seconds and its range spans seconds to weeks, so 90 has two readings and guessing mints a credential that dies in ninety seconds. A page's lifetime has one unit, the server's own skill document teaches ?ttl=30, and refusing the spelling an agent just read there would be a second grammar to no purpose.

12h is refused rather than rounded. It is a perfectly good duration this server cannot store — the only ways to honour it are to round to a lifetime nobody typed, or to say so. The message names the unit, so the correction is arithmetic the caller can do.

What this deliberately does not do

PageTTL has no maximum. That bound belongs to the server's PageLifetime, the way the slug rules belong to its Slug — a copy here would be a second source of truth that drifts the day the server moves it, and the 400 it earns names the real limit. What is checked locally is only what the server cannot check for us: that the input means a number of days, and that turning it into one does not overflow.

The deadline is always read off the response, never computed. The server resolves it against its clock at the moment of upload, applies its own default when we say nothing, and on update reports a date this side never knew.

update takes no --ttl at all. A page's lifetime is fixed at publish, and the server answers ?ttl= on PUT with a 400 rather than a 200 that ignored it.

Two hand-written encoders

PageLocation emits an explicit null for a kept page. The synthesised encodeIfPresent drops the key, which would leave --json unable to distinguish "no deadline" from "this tool has no opinion about deadlines" — the same reason the server hand-writes its own encoder.

Decoding treats null and absent alike, which is not a shortcut: a deployment older than page expiry omits the key, and nothing expires on that server either. Both honestly mean "no deadline".

One thing outside the feature

The smoke test's run_stele was merging stderr into stdout (2>&1), which would have swallowed the new deadline line into $OUT and broken the URL assertions. The streams are now split into $OUT/$ERR. No existing call site read stderr through $OUT — the one place that does, the auth login TTY check, has its own inline invocation — and the split makes the stdout/stderr discipline testable, which it was not before.

Testing

105 unit tests pass, covering the grammar, the sub-day refusal, the overflow paths, the ?ttl= wire spelling, and both JSON shapes for "no deadline".

⚠️ The integration smoke test is unrun. There is no stored credential on the machine this was written on and stele auth login needs a TTY. Its new legs — default lifetime is set, --ttl never yields an explicit JSON null, --ttl 90 differs from the default, the server rejects an over-long lifetime, update --ttl is refused — are written but have not run against a live server. Worth doing before merge.

Still open

Both server-side features this CLI still lacks, neither addressed here: no delete command despite DELETE /pages/:slug landing in stele-pages c83463a, and Scope has no delete case.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added configurable page lifetimes when publishing, including day-based durations and pages that never expire.
    • Added expiration details to page locations and command output.
    • Added validation and clear errors for invalid lifetime values.
    • Improved URL handling for slugs and client names.
  • Documentation

    • Documented page lifetime behavior, supported formats, defaults, and update restrictions.
    • Clarified that expiration details appear on stderr while URLs remain on stdout.
  • Bug Fixes

    • Improved credential default-host handling and safe URL encoding.
    • Confirmed updates preserve the original page deadline.

The server has had per-page expiry since stele-pages#17 and this client knew
nothing about it. `PageLocation` decoded `slug` and `url` and dropped the
`expires` the server sent, so `stele publish` printed a URL for a page that
would stop being served in a matter of days and said nothing about it. The
default is ephemeral by design — a page that outlives its purpose fails
quietly and forever — but that only works if the caller is told.

So: `--ttl <days|never>`, and the deadline printed under the URL.

`PageTTL` is a second lifetime grammar and deliberately not `ExpiryDuration`.
A credential's lifetime is seconds and spans seconds to weeks, so `90` there
has two readings and guessing mints a credential that dies in ninety seconds;
a page's is whole days, the server's own document teaches `?ttl=30`, and
refusing the spelling an agent just read there would be a second grammar to no
purpose. A bare number is therefore days. What is refused is `12h` — a good
duration this server cannot store, honourable only by rounding to a lifetime
nobody typed.

It has no maximum. That bound is the server's `PageLifetime`, the way the slug
rules are its `Slug`; a copy here would drift the day the server moved it, and
the 400 names the real limit. What is checked locally is only what the server
cannot check for us: that the input means a number of days, and that turning
it into one does not overflow.

The deadline is always read off the response, never computed. The server
resolves it against its clock at upload, applies its own default when we say
nothing, and on `update` reports a date this side never knew. `update` takes
no --ttl at all: a page's lifetime is fixed at publish, and the server answers
?ttl= on PUT with a 400 rather than a 200 that ignored it.

Two encoders earn their hand-written middle line. `PageLocation` emits an
explicit null for a kept page because the synthesised `encodeIfPresent` drops
the key, leaving `--json` unable to distinguish "no deadline" from "this tool
has no opinion about deadlines" — the same reason the server hand-writes its
own. Decoding treats null and absent alike, which is not a shortcut: a
deployment older than page expiry omits the key, and nothing expires there
either.

The smoke test's run_stele stopped merging stderr into stdout. The split is a
promise this tool makes — `url=$(stele publish page.html)` — and merging the
streams is what would have hidden the deadline landing on the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: e1766a21-c87f-4402-ae44-15e9ec20dcc4

📥 Commits

Reviewing files that changed from the base of the PR and between 96ae4e6 and 76a8e63.

📒 Files selected for processing (10)
  • README.md
  • Sources/SteleKit/Credentials.swift
  • Sources/SteleKit/Inputs.swift
  • Sources/SteleKit/Models.swift
  • Sources/SteleKit/SteleClient.swift
  • Sources/stele/PageCommands.swift
  • Tests/SteleKitTests/CredentialsTests.swift
  • Tests/SteleKitTests/InputsTests.swift
  • Tests/SteleKitTests/SteleClientTests.swift
  • scripts/integration-smoke.sh

📝 Walkthrough

Walkthrough

The change adds page lifetime support across the SDK, CLI, documentation, and integration tests. It models expiry metadata, validates and sends TTL values, reports deadlines on stderr, preserves update deadlines, improves credential default handling, and encodes route segments safely.

Changes

Page lifetime flow

Layer / File(s) Summary
Lifetime contracts and expiry model
Sources/SteleKit/Inputs.swift, Sources/SteleKit/Models.swift, Tests/SteleKitTests/InputsTests.swift, Tests/SteleKitTests/SteleClientTests.swift
PageTTL parses supported lifetime values and serializes the ttl query value. PageLocation decodes and encodes the expires field, including null.
Publish and update lifetime flow
Sources/SteleKit/SteleClient.swift, Sources/stele/PageCommands.swift, Tests/SteleKitTests/SteleClientTests.swift
Publishing accepts optional TTL values. The CLI validates and forwards --ttl, reports lifetime data on stderr, and documents that updates preserve deadlines.
End-to-end lifetime validation
README.md, scripts/integration-smoke.sh
Documentation defines page lifetime behavior. Smoke tests verify output streams, TTL variants, server limits, expiry encoding, and update rejection.

Credential and route handling

Layer / File(s) Summary
Incumbent credential defaults
Sources/SteleKit/Credentials.swift, Tests/SteleKitTests/CredentialsTests.swift
Credential writes preserve an incumbent default when possible and retain ambiguity for multi-host stores without a default.
Encoded host and route segments
Sources/SteleKit/Credentials.swift, Sources/SteleKit/SteleClient.swift, Tests/SteleKitTests/SteleClientTests.swift
Host paths and page/client identifiers use percent-encoded route segments. Dot-segment and slash injection cases are covered by tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI as PublishCommand
  participant SDK as SteleClient
  participant Server
  participant Output as PageIO.report
  CLI->>CLI: Parse --ttl
  CLI->>SDK: Publish page with PageTTL
  SDK->>Server: Send ttl query parameter
  Server-->>SDK: Return PageLocation with expiresAt
  SDK-->>Output: Return page location
  Output-->>CLI: Write URL to stdout
  Output-->>CLI: Write lifetime to stderr
Loading

Poem

A rabbit hops through TTL days,
Past URLs and deadlines’ rays.
“Never” keeps the page in sight,
While updates guard its lasting night.
Safe paths curl, credentials stay—
The burrow ships another day!


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login.

Comment @coderabbitai help to get the list of available commands.

…sserts

`--no-default` was making the new host the default. `set` read `defaultHost`
to decide whether one existed, but `encode` leaves the marker out of a
one-host file, so the incumbent reloaded as nil and the host that declined
took it. It now reads the incumbent — the marker, or the sole stored host —
before inserting, and leaves an ambiguous file ambiguous.

Slugs and client names are percent-encoded into their path segment. Left
raw, dot-segment removal turned `update ../admin/clients` into
`PUT /admin/clients`; the credential never left its own host, but the
request was not for the resource that was named.

Three smoke-script asserts were not asserting: `*"$(printf '\n')"*` is
`*""*` and matched every string (aborting the run before the lifetime
checks), an unknown option exits 64 rather than 1, and the `--ttl 90`
comparison could pass on the empty output of a failed publish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ProJedi1234
ProJedi1234 merged commit 6bd53ee into main Aug 5, 2026
2 checks passed
@ProJedi1234
ProJedi1234 deleted the feat/page-ttl branch August 5, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant