Skip to content

Commit 2d0bd5f

Browse files
Restutaclaude
andauthored
Simplify page expiration to be consumer-driven (#19)
Page expiration was over-built: it included an operator-side, server-read namespace policy (PUB_NAMESPACE_CONFIG) that made enabling expiration a deployment concern. That decision belongs to the publisher, not the operator. - Remove src/core/namespace-config.ts, PUB_NAMESPACE_CONFIG, and the resolveNamespaceExpiration option. The server now simply honors the expiration it is sent. - Let a consumer set a default in their own CLI config (~/.config/pub/config.json): top-level `defaultExpires` and/or per-namespace `expires`. The CLI sends the explicit --expires flag as `expires` and the config default as a separate, lower-priority `defaultExpires`. - Precedence (most specific wins): --expires flag → document frontmatter/ <meta> → namespace config → global default. This keeps a page's own `expires: never` able to pin it even under a namespace default. Per-page expiration needs no server configuration at all. Claude-Session: https://claude.ai/code/session_01YNt2ZWLzXwnH6zonhVSQ41 Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd46c8f commit 2d0bd5f

13 files changed

Lines changed: 152 additions & 279 deletions

File tree

README.md

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,19 @@ disappears from `pubmd list`.
245245
A TTL can be set at three levels — **most specific wins**:
246246

247247
1. **Per page** — a CLI flag, frontmatter, or an HTML `<meta>` tag.
248-
2. **Per namespace** — server-side policy, so sensitive namespaces can expire
249-
everything by default.
250-
3. **Global default** — server-side fallback (off unless configured).
248+
2. **Per namespace (your config)** — a default in your CLI config for a given
249+
namespace, so all your publishes to a sensitive namespace expire.
250+
3. **Your global default** — a default in your CLI config applied to every
251+
publish.
252+
253+
It's all consumer-driven: the publisher chooses the TTL, the server just honors
254+
it. There is no server-side configuration to enable expiration.
251255

252256
When expiration is turned on but no duration is given, the default is **14 days**.
253257

254258
Durations accept `m` (minutes), `h` (hours), `d` (days), and `w` (weeks); a bare
255259
number means days. `true` means "use the default", and `never` / `false` opts a
256-
page out (even of a namespace policy).
260+
publish out (even of a config default).
257261

258262
### Per page
259263

@@ -282,24 +286,30 @@ includes an `expiresAt` timestamp (or `null` when the page never expires).
282286
Re-publishing identical content keeps the original deadline; changing the
283287
content (or the TTL) resets the clock.
284288

285-
### Per namespace (server config)
289+
### Default for your publishes (CLI config)
286290

287-
The server reads a per-namespace policy from `PUB_NAMESPACE_CONFIG`, which is
288-
either a path to a JSON file or inline JSON:
291+
To avoid passing `--expires` every time, set a default in your own config file
292+
(`~/.config/pub/config.json`). Use a top-level `defaultExpires` for all your
293+
publishes, and/or an `expires` under a specific namespace so everything you
294+
publish there expires:
289295

290296
```json
291297
{
292-
"default": { "expires": false },
298+
"apiBaseUrl": "https://bul.sh",
299+
"defaultNamespace": "me",
300+
"defaultExpires": false,
293301
"namespaces": {
294-
"secret": { "expires": true },
295-
"scratch": { "expires": "7d" }
302+
"me": { "token": "" },
303+
"secret": { "token": "", "expires": "14d" },
304+
"scratch":{ "token": "", "expires": "7d" }
296305
}
297306
}
298307
```
299308

300-
With this config, every page published to `secret` expires after the default
301-
14 days and everything in `scratch` after 7 days — unless the page sets its own
302-
`expires` (including `expires: never` to pin it).
309+
Precedence is most-specific-wins: `--expires` flag → the page's own
310+
frontmatter / `<meta>` → the namespace's `expires``defaultExpires`. So a
311+
document with `expires: never` still pins itself even when its namespace
312+
defaults to expiring, and `--expires never` overrides everything for one publish.
303313

304314
Expired pages are hidden lazily — they return `404` and drop out of `list`
305315
immediately, but the underlying record stays in storage until the slug is

docs/progress.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@
1919
- 2026-04-03: Added Obsidian-style callout rendering with aliases, collapsible `+`/`-` support, and type-specific styling. Verified with `npm run verify` and local browser QA screenshots.
2020
- 2026-06-25: Added opt-in review annotations for published markdown/HTML pages. `--review`, markdown `review: true`, or HTML `<meta name="pubmd:review" content="true">` injects inline comments, hover targeting, per-comment deletion, and a copyable feedback prompt while preserving raw source.
2121
- 2026-06-25: Refined review annotations into comment-mode sharing. `--comments`, markdown `comments: true`, and HTML `<meta name="pubmd:comments" content="true">` are now the preferred opt-ins. The CLI prints `?comments=1` for commentable pages, and removing the query string keeps the same page readable without comment UI.
22-
- 2026-06-29: Added expiring pages (TTL). Pages never expire by default; a TTL can be set per page (`--expires`, frontmatter `expires:`, or `<meta name="pubmd:expires">`), per namespace (server `PUB_NAMESPACE_CONFIG` policy so sensitive namespaces expire by default), or via a global default, with most-specific-wins precedence and a 14-day default when no duration is given. Expired pages return 404 and drop out of `list`; the CDN cache is capped at the deadline. Verified with `npm run verify`.
22+
- 2026-06-29: Added expiring pages (TTL). Pages never expire by default; a TTL can be set per page (`--expires`, frontmatter `expires:`, or `<meta name="pubmd:expires">`), with a 14-day default when expiration is enabled without a duration. Expired pages return 404 and drop out of `list`; the CDN cache is capped at the deadline. Verified with `npm run verify`.
23+
- 2026-06-29: Simplified expiration to be fully consumer-driven. Removed the operator-side `PUB_NAMESPACE_CONFIG` server policy (and the `namespace-config` module) — the server now just honors the `expires` it is sent. Consumers set a default in their own CLI config (`~/.config/pub/config.json`): top-level `defaultExpires` and/or per-namespace `expires`, with precedence flag → namespace → global. No server configuration is required to use expiration.

docs/project-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Local .pub mapping:
165165
- [ ] Page renames with redirects — data model already supports this
166166
- [ ] View count analytics
167167
- [ ] Page collections with auto-generated index
168-
- [x] Expiring pages (TTL) — per-page (flag/frontmatter/meta), per-namespace policy, 14-day default
168+
- [x] Expiring pages (TTL) — per-page (flag/frontmatter/meta) + consumer config default, 14-day default
169169
- [ ] Custom domains (namespace.pub.domain)
170170
- [ ] Web editor (CodeMirror with markdown + live preview)
171171
- [ ] Batch publish API

src/cli/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import path from "node:path";
44

55
import { z } from "zod";
66

7+
import { ExpirationSettingSchema } from "../core/contract.js";
8+
79
const NamespaceConfigSchema = z.object({
810
token: z.string().min(1),
11+
// Optional per-namespace default expiration for this consumer's publishes.
12+
// A duration ("7d"), a number of days, true (14-day default), or never.
13+
expires: ExpirationSettingSchema.optional(),
914
});
1015

1116
const ConfigSchema = z.object({
1217
apiBaseUrl: z.string().url().optional(),
1318
defaultNamespace: z.string().optional(),
19+
// Optional default expiration applied to every publish unless the namespace
20+
// or the publish itself overrides it.
21+
defaultExpires: ExpirationSettingSchema.optional(),
1422
namespaces: z.record(z.string(), NamespaceConfigSchema),
1523
});
1624

src/cli/main.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ListPagesResponseSchema,
1010
PublishedPageSchema,
1111
} from "../core/contract.js";
12+
import type { ExpirationSetting } from "../core/expiration.js";
1213
import { inlineHtmlAssets } from "../core/inline-html.js";
1314
import { parseMarkdownDocument } from "../core/markdown.js";
1415
import { prepareMarkdownBodyForPublish } from "../core/publish-markdown.js";
@@ -165,7 +166,12 @@ async function runPublish(context: CommandContext): Promise<void> {
165166
existingPage?.pageId === undefined ? {} : { pageId: existingPage.pageId };
166167
const reviewAnnotations =
167168
options.review === true || options.comments === true;
169+
// The --expires flag is the explicit per-publish override; the consumer's
170+
// config (per-namespace, then global) is a lower-priority default that the
171+
// server ranks below the document's own frontmatter/<meta>.
168172
const expires = options.expires;
173+
const defaultExpires =
174+
config.namespaces[namespace]?.expires ?? config.defaultExpires;
169175
const requestBody =
170176
absoluteFilePath !== undefined && /\.html?$/i.test(absoluteFilePath)
171177
? await buildHtmlRequestBody(
@@ -174,13 +180,15 @@ async function runPublish(context: CommandContext): Promise<void> {
174180
pageIdPart,
175181
reviewAnnotations,
176182
expires,
183+
defaultExpires,
177184
)
178185
: await buildMarkdownRequestBody(
179186
absoluteFilePath,
180187
slug,
181188
pageIdPart,
182189
reviewAnnotations,
183190
expires,
191+
defaultExpires,
184192
);
185193

186194
const response = await fetch(
@@ -255,7 +263,8 @@ async function buildMarkdownRequestBody(
255263
slug: string | undefined,
256264
pageIdPart: { pageId?: string },
257265
reviewAnnotations: boolean,
258-
expires: string | undefined,
266+
expires: ExpirationSetting,
267+
defaultExpires: ExpirationSetting,
259268
): Promise<Record<string, unknown>> {
260269
const markdown =
261270
absoluteFilePath === undefined
@@ -274,6 +283,7 @@ async function buildMarkdownRequestBody(
274283
...(renderMarkdown === undefined ? {} : { renderMarkdown }),
275284
...(shouldInjectReview ? { reviewAnnotations: true } : {}),
276285
...(expires === undefined ? {} : { expires }),
286+
...(defaultExpires === undefined ? {} : { defaultExpires }),
277287
...(slug === undefined ? {} : { slug }),
278288
...pageIdPart,
279289
};
@@ -284,7 +294,8 @@ async function buildHtmlRequestBody(
284294
slug: string | undefined,
285295
pageIdPart: { pageId?: string },
286296
reviewAnnotations: boolean,
287-
expires: string | undefined,
297+
expires: ExpirationSetting,
298+
defaultExpires: ExpirationSetting,
288299
): Promise<Record<string, unknown>> {
289300
const source = await readFile(absoluteFilePath, "utf8");
290301
const inlined = await inlineHtmlAssets(source, {
@@ -312,6 +323,7 @@ async function buildHtmlRequestBody(
312323
document,
313324
...(shouldInjectReview ? { reviewAnnotations: true } : {}),
314325
...(expires === undefined ? {} : { expires }),
326+
...(defaultExpires === undefined ? {} : { defaultExpires }),
315327
slug: slug ?? fallbackSlug,
316328
...pageIdPart,
317329
};
@@ -539,7 +551,9 @@ function printHelp(): void {
539551
540552
--expires <when> sets a time-to-live: a duration like 7d, 12h, 30m or 2w,
541553
or "true" for the default 14 days, or "never". Pages never expire unless a
542-
TTL is set here, in frontmatter (expires:), or by namespace policy.`);
554+
TTL is set here, in frontmatter (expires:), or as a default in your config
555+
(~/.config/pub/config.json: top-level "defaultExpires", or "expires" under a
556+
namespace).`);
543557
}
544558

545559
main().catch((error: unknown) => {

src/core/contract.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export const PublishPageRequestSchema = z.object({
4545
renderMarkdown: z.string().min(1).optional(),
4646
reviewAnnotations: z.boolean().optional(),
4747
expires: ExpirationSettingSchema.optional(),
48+
/** Lower-priority fallback than `expires`/frontmatter (the publisher's config default). */
49+
defaultExpires: ExpirationSettingSchema.optional(),
4850
slug: NameSchema.optional(),
4951
pageId: z.string().uuid().optional(),
5052
});
@@ -57,6 +59,8 @@ export const PublishHtmlRequestSchema = z.object({
5759
document: z.string().min(1).optional(),
5860
reviewAnnotations: z.boolean().optional(),
5961
expires: ExpirationSettingSchema.optional(),
62+
/** Lower-priority fallback than `expires`/`<meta>` (the publisher's config default). */
63+
defaultExpires: ExpirationSettingSchema.optional(),
6064
title: z.string().trim().min(1).max(200).optional(),
6165
description: z.string().trim().min(1).max(300).optional(),
6266
noindex: z.boolean().optional(),

src/core/namespace-config.ts

Lines changed: 0 additions & 111 deletions
This file was deleted.

src/core/publish-service.ts

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
parseMarkdownDocument,
2020
renderMarkdownToHtml,
2121
} from "./markdown.js";
22-
import type { NamespaceExpirationResolver } from "./namespace-config.js";
2322
import {
2423
AuthenticationError,
2524
NamespaceExistsError,
@@ -47,6 +46,8 @@ export interface PublishPageInput {
4746
description?: string;
4847
noindex?: boolean;
4948
expires?: ExpirationSetting;
49+
/** Lower-priority fallback than `expires` and the document's own setting. */
50+
defaultExpires?: ExpirationSetting;
5051
// common
5152
namespace: string;
5253
pageId?: string;
@@ -90,26 +91,13 @@ export interface PublishService {
9091
export interface PublishServiceOptions {
9192
/** Clock injection for testability; defaults to `Date.now`. */
9293
now?: () => number;
93-
/** Per-namespace expiration policy; defaults to "no policy". */
94-
resolveNamespaceExpiration?: NamespaceExpirationResolver;
9594
}
9695

9796
export function createPublishService(
9897
repository: PublishRepository,
9998
options: PublishServiceOptions = {},
10099
): PublishService {
101100
const now = options.now ?? (() => Date.now());
102-
const resolveNamespaceExpiration =
103-
options.resolveNamespaceExpiration ?? (() => undefined);
104-
105-
function resolvePageExpirationMs(
106-
namespace: string,
107-
pageSetting: ExpirationSetting,
108-
): number | null {
109-
return resolveExpirationMs(
110-
coalesceExpiration(pageSetting, resolveNamespaceExpiration(namespace)),
111-
);
112-
}
113101

114102
async function claimNamespace(
115103
namespace: string,
@@ -153,9 +141,14 @@ export function createPublishService(
153141
const slug = safeSlug;
154142
const nowMs = now();
155143
const nowIso = new Date(nowMs).toISOString();
156-
const expirationMs = resolvePageExpirationMs(
157-
safeNamespace,
158-
coalesceExpiration(input.expires, parsed.frontmatter.expires),
144+
// Precedence: explicit per-publish `expires` → document frontmatter →
145+
// the publisher's config default. Most specific wins.
146+
const expirationMs = resolveExpirationMs(
147+
coalesceExpiration(
148+
input.expires,
149+
parsed.frontmatter.expires,
150+
input.defaultExpires,
151+
),
159152
);
160153
const markdownBlobKey = `${pageId}.md`;
161154
const htmlBlobKey = `${pageId}.html`;
@@ -271,11 +264,13 @@ export function createPublishService(
271264
const pageId = existingPage?.pageId ?? randomUUID();
272265
const nowMs = now();
273266
const nowIso = new Date(nowMs).toISOString();
274-
const expirationMs = resolvePageExpirationMs(
275-
safeNamespace,
267+
// Precedence: explicit per-publish `expires` → document `<meta>` → the
268+
// publisher's config default. Most specific wins.
269+
const expirationMs = resolveExpirationMs(
276270
coalesceExpiration(
277271
input.expires,
278272
meta.expires === null ? undefined : meta.expires,
273+
input.defaultExpires,
279274
),
280275
);
281276
const sourceBlobKey = `${pageId}.html.src`;

0 commit comments

Comments
 (0)