diff --git a/README.md b/README.md index 1b0ef77..723c046 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The service provides: - A browser UI for uploading, replacing, downloading, and deleting artifacts - A shared Codex and Claude Code skill for agent-driven publishing - A command-line publisher that can update an existing artifact URL +- Three-day artifact expiration by default, with per-publish overrides - Sandboxed HTML previews without `allow-same-origin` ### Publishing options @@ -93,6 +94,8 @@ Useful options: ```sh --slug architecture-report # Reuse the URL on future updates --share person@example.com # Set additional exact-email recipients +--expires-in 1h # Override the default three-day lifetime +--expires-in never # Keep the artifact until it is deleted --public # Allow anyone with the link to view --no-open # Do not open the result in a browser -- --option-like-name.html # Publish a filename beginning with "-" @@ -101,7 +104,12 @@ Useful options: Workspace viewers always retain access and are independent of `--share`. On update, supplying `--share` replaces only the artifact's additional exact-email invitation list; omitting it preserves existing access. Omitting `--public` on -update preserves the public setting. The publisher refuses to combine a +update preserves the public setting. New artifacts expire after three days, and +every republish resets that three-day timer unless `--expires-in` supplies a +duration or `never`. Expired artifacts stop being readable immediately and are +removed when the owner next opens the library or publishes an artifact. Existing +artifacts from deployments upgraded to this version remain non-expiring until +their next update. The publisher refuses to combine a process-level URL override with a token loaded from the configuration file; override `ARTIFACTS_URL` and `ARTIFACTS_PUBLISH_TOKEN` together. @@ -233,6 +241,11 @@ The publishing token is deployment-wide owner automation authority. Anyone holdi Lakebed currently limits capsule state to 1 MiB. This project limits one artifact to 512 KiB, individual chunks to 48 KiB, and total artifact HTML to 768 KiB, reserving the remaining state for metadata, access grants, and indexes. Treat the deployment as a small visual-document workspace, not general hosting. Delete superseded artifacts and monitor usage with Lakebed inspection tools. +Expiration is enforced server-side. Because Lakebed v0 has no scheduled-job API, +expired rows are reclaimed lazily during the next owner library visit or publish; +they are excluded from reads and capacity calculations as soon as their timestamp +passes. + Public artifacts are subject to the [Lakebed Acceptable Use Policy](https://lakebed.dev/acceptable-use). The deployment owner is responsible for its published content and recipients. ## Local development diff --git a/client/index.tsx b/client/index.tsx index 2437d30..1bbf0b6 100644 --- a/client/index.tsx +++ b/client/index.tsx @@ -13,6 +13,7 @@ import { import { useEffect, useMemo, useState } from "preact/hooks"; import type app from "../server"; import { + DEFAULT_EXPIRATION_SECONDS, MAX_ARTIFACT_BYTES, MAX_TOTAL_ARTIFACT_BYTES, artifactHref, @@ -26,6 +27,14 @@ import { const client = createClient(); const KNOWN_EMAILS_KEY = "codex-artifacts:known-emails"; +const EXPIRATION_OPTIONS = [ + { label: "1 hour", value: "3600" }, + { label: "1 day", value: "86400" }, + { label: "3 days (default)", value: String(DEFAULT_EXPIRATION_SECONDS) }, + { label: "1 week", value: "604800" }, + { label: "30 days", value: "2592000" }, + { label: "Never", value: "never" } +] as const; function formatBytes(value: number): string { if (value < 1024) return `${value} B`; @@ -39,6 +48,14 @@ function formatDate(value: string): string { }).format(new Date(value)); } +function expirationValue(value: FormDataEntryValue | string | null): number | null { + return value === "never" ? null : Number(value || DEFAULT_EXPIRATION_SECONDS); +} + +function expirationLabel(expiresAt: string | null): string { + return expiresAt ? `expires ${formatDate(expiresAt)}` : "never expires"; +} + function slugFromTitle(title: string): string { const base = cleanSlug(title) || "artifact"; return `${base}-${Date.now().toString(36)}`; @@ -148,7 +165,12 @@ function NewArtifactForm() { const html = await file.text(); const title = requestedTitle || file.name.replace(/\.html?$/i, ""); const slug = slugFromTitle(title); - const result = await publishArtifact({ title, slug, chunks: chunkHtml(html) }); + const result = await publishArtifact({ + title, + slug, + chunks: chunkHtml(html), + expiresInSeconds: expirationValue(data.get("expiresInSeconds")) + }); const url = `${window.location.origin}${artifactHref(result.slug)}`; setNotice(url); form.reset(); @@ -165,9 +187,10 @@ function NewArtifactForm() {

New artifact

Publish an HTML file

-

Owners and workspace viewers can open every artifact; additional recipients can be added per artifact. Maximum {formatBytes(MAX_ARTIFACT_BYTES)} per artifact; {formatBytes(MAX_TOTAL_ARTIFACT_BYTES)} workspace HTML budget.

+

Artifacts expire after three days by default, freeing their storage automatically. Owners and workspace viewers can open every artifact; additional recipients can be added per artifact.

+

Maximum {formatBytes(MAX_ARTIFACT_BYTES)} per artifact; {formatBytes(MAX_TOTAL_ARTIFACT_BYTES)} workspace HTML budget.

-
void submit(event)}> + void submit(event)}> +
@@ -195,8 +224,10 @@ type OwnedArtifact = NonNullable {artifact.title}

{formatBytes(Number(artifact.sizeBytes))} · updated {formatDate(artifact.updatedAt)}

+

{expirationLabel(artifact.expiresAt)}

{accessLabel(artifact)} @@ -252,6 +298,15 @@ function ArtifactCard({ artifact }: { artifact: OwnedArtifact }) { +
+ + +
{status ?

{status}

: null} @@ -260,6 +315,10 @@ function ArtifactCard({ artifact }: { artifact: OwnedArtifact }) { function OwnerDashboard() { const artifacts = client.useQuery("ownedArtifacts"); + const pruneExpiredArtifacts = client.useMutation("pruneExpiredArtifacts"); + useEffect(() => { + void pruneExpiredArtifacts(); + }, []); const usedBytes = artifacts?.reduce( (total, artifact) => total + Number(artifact.sizeBytes), 0 @@ -609,7 +668,7 @@ function ArtifactFrame({ requestedSlug }: { requestedSlug?: string }) {

{artifact.title}

-

{formatBytes(artifact.sizeBytes)} · sandboxed preview

+

{formatBytes(artifact.sizeBytes)} · {expirationLabel(artifact.expiresAt)} · sandboxed preview

diff --git a/openapi.yaml b/openapi.yaml index b14947b..c4a3022 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -28,6 +28,8 @@ paths: The bearer token is deployment-wide owner automation authority. If `slug` identifies an existing artifact, its HTML and title are replaced. Omitted `sharedWith` and `isPublic` values preserve existing settings during updates. + Omitted `expiresInSeconds` values start or reset the three-day expiration + timer. Set it to `null` for an artifact that never expires. Deployment-configured workspace viewers retain read access independently of `sharedWith`. security: @@ -113,10 +115,20 @@ components: format: email isPublic: type: boolean + expiresInSeconds: + description: | + Relative lifetime in whole seconds, from 60 seconds through one year. + Omit for the three-day default or set to null to never expire. The + lifetime starts again whenever the artifact is published or replaced. + oneOf: + - type: integer + minimum: 60 + maximum: 31536000 + - type: "null" PublishResponse: type: object additionalProperties: false - required: [id, slug, updated, isPublic] + required: [id, slug, updated, isPublic, expiresAt] properties: id: type: string @@ -126,6 +138,12 @@ components: type: boolean isPublic: type: boolean + expiresAt: + description: Absolute expiration time, or null when expiration is disabled. + oneOf: + - type: string + format: date-time + - type: "null" Error: type: object additionalProperties: false diff --git a/server/index.ts b/server/index.ts index 58c1e34..337e480 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,6 +23,8 @@ import { cleanSlug, cleanTitle, emailDomain, + expirationTimestamp, + isArtifactExpired, isValidEmail, normalizeEmail, normalizeSharedDomains, @@ -41,6 +43,7 @@ const schema = { sharedWith: string().default("[]"), sharedDomains: string().default("[]"), isPublic: boolean().default(false), + expiresAt: string().default(""), sizeBytes: string(), chunkCount: string() }) @@ -102,6 +105,7 @@ type PublishInput = { chunks: string[]; sharedWith?: string[]; isPublic?: boolean; + expiresInSeconds?: number | null; }; function ownerEmails(ctx: EnvironmentContext): string[] { @@ -332,6 +336,7 @@ function validatePublishInput(input: PublishInput, configuredOwners: string[]) { const slug = cleanSlug(input.slug); const sizeBytes = validateChunks(input.chunks); const sharedWith = normalizeSharedEmails(input.sharedWith ?? [], configuredOwners); + const expiresAt = expirationTimestamp(input.expiresInSeconds); if (!title) { throw new Error("Title is required."); @@ -343,7 +348,7 @@ function validatePublishInput(input: PublishInput, configuredOwners: string[]) { throw new Error(`At most ${MAX_SHARED_EMAILS} people can be added.`); } - return { title, slug, sizeBytes, sharedWith }; + return { title, slug, sizeBytes, sharedWith, expiresAt }; } async function replaceChunks(ctx: AppContext, artifactId: string, chunks: string[]) { @@ -364,6 +369,32 @@ async function replaceChunks(ctx: AppContext, artifactId: string, chunks: string } } +async function deleteArtifactData(ctx: AppContext, artifactId: string) { + const chunks = await ctx.db.artifactChunks + .withIndex("by_artifact_part", (q) => q.eq("artifactId", artifactId)) + .collect(); + for (const chunk of chunks) { + await ctx.db.artifactChunks.delete(chunk.id); + } + + const grants = await ctx.db.artifactGrants + .withIndex("by_artifact", (q) => q.eq("artifactId", artifactId)) + .collect(); + for (const grant of grants) { + await ctx.db.artifactGrants.delete(grant.id); + } + await ctx.db.artifacts.delete(artifactId); +} + +async function removeExpiredArtifacts(ctx: AppContext): Promise { + const artifacts = await ctx.db.artifacts.withIndex("by_creation").collect(); + const expired = artifacts.filter((artifact) => isArtifactExpired(artifact.expiresAt)); + for (const artifact of expired) { + await deleteArtifactData(ctx, artifact.id); + } + return expired.length; +} + async function requireArtifactCapacity( ctx: AppContext, nextSizeBytes: number, @@ -394,7 +425,8 @@ async function publishAsOwner( ctx: AppContext, input: PublishInput, ownerId: string -): Promise<{ id: string; slug: string }> { +): Promise<{ id: string; slug: string; expiresAt: string | null }> { + await removeExpiredArtifacts(ctx); const configuredOwners = ownerEmails(ctx); const configuredWorkspaceViewers = workspaceViewerEmails(ctx, configuredOwners); const implicitAccessEmails = [ @@ -432,6 +464,7 @@ async function publishAsOwner( slug: validated.slug, sharedWith: JSON.stringify(validated.sharedWith), isPublic: input.isPublic ?? artifact.isPublic, + expiresAt: validated.expiresAt, sizeBytes: String(validated.sizeBytes), chunkCount: String(input.chunks.length) }); @@ -441,7 +474,11 @@ async function publishAsOwner( validated.sharedWith, parseSharedEmails(artifact.sharedDomains) ); - return { id: artifact.id, slug: validated.slug }; + return { + id: artifact.id, + slug: validated.slug, + expiresAt: validated.expiresAt || null + }; } const existing = await ctx.db.artifacts @@ -460,11 +497,16 @@ async function publishAsOwner( sharedWith: JSON.stringify(validated.sharedWith), sharedDomains: "[]", isPublic: input.isPublic === true, + expiresAt: validated.expiresAt, sizeBytes: String(validated.sizeBytes), chunkCount: String(input.chunks.length) }); await replaceChunks(ctx, createdArtifact.id, input.chunks); - return { id: createdArtifact.id, slug: validated.slug }; + return { + id: createdArtifact.id, + slug: validated.slug, + expiresAt: validated.expiresAt || null + }; } export default capsule({ @@ -496,13 +538,16 @@ export default capsule({ .order("desc") .collect(); - return artifacts.map((artifact) => ({ - ...artifact, - sharedWith: parseSharedEmails(artifact.sharedWith), - sharedDomains: parseSharedEmails(artifact.sharedDomains), - workspaceViewerCount, - isPublic: artifact.isPublic === true - })); + return artifacts + .filter((artifact) => !isArtifactExpired(artifact.expiresAt)) + .map((artifact) => ({ + ...artifact, + expiresAt: artifact.expiresAt || null, + sharedWith: parseSharedEmails(artifact.sharedWith), + sharedDomains: parseSharedEmails(artifact.sharedDomains), + workspaceViewerCount, + isPublic: artifact.isPublic === true + })); }), artifactBySlug: query(async (ctx, slugInput: string) => { @@ -510,7 +555,7 @@ export default capsule({ const artifact = await ctx.db.artifacts .withIndex("by_slug", (q) => q.eq("slug", slug)) .first(); - if (!artifact) { + if (!artifact || isArtifactExpired(artifact.expiresAt)) { return null; } @@ -552,6 +597,7 @@ export default capsule({ html: chunks.map((chunk) => chunk.content).join(""), sizeBytes: Number(artifact.sizeBytes), updatedAt: artifact.updatedAt, + expiresAt: artifact.expiresAt || null, isPublic, canManage, ownerEmails: canManage ? configuredOwners : [], @@ -587,7 +633,7 @@ export default capsule({ const artifact = await ctx.db.artifacts .withIndex("by_slug", (q) => q.eq("slug", slug)) .first(); - if (!artifact) { + if (!artifact || isArtifactExpired(artifact.expiresAt)) { return { accepted: false }; } if (artifact.isPublic === true) { @@ -630,6 +676,26 @@ export default capsule({ return publishAsOwner(ctx, input, ctx.auth.userId); }), + setArtifactExpiration: mutation(async ( + ctx, + artifactId: string, + expiresInSeconds: number | null + ) => { + await requireOwner(ctx); + const artifact = await ctx.db.artifacts.get(artifactId); + if (!artifact || isArtifactExpired(artifact.expiresAt)) { + throw new Error("Artifact not found."); + } + const expiresAt = expirationTimestamp(expiresInSeconds); + await ctx.db.artifacts.update(artifact.id, { expiresAt }); + return { expiresAt: expiresAt || null }; + }), + + pruneExpiredArtifacts: mutation(async (ctx) => { + await requireOwner(ctx); + return { removed: await removeExpiredArtifacts(ctx) }; + }), + setArtifactAccess: mutation(async ( ctx, artifactId: string, @@ -670,19 +736,7 @@ export default capsule({ throw new Error("Artifact not found."); } - const chunks = await ctx.db.artifactChunks - .withIndex("by_artifact_part", (q) => q.eq("artifactId", artifact.id)) - .collect(); - for (const chunk of chunks) { - await ctx.db.artifactChunks.delete(chunk.id); - } - const grants = await ctx.db.artifactGrants - .withIndex("by_artifact", (q) => q.eq("artifactId", artifact.id)) - .collect(); - for (const grant of grants) { - await ctx.db.artifactGrants.delete(grant.id); - } - await ctx.db.artifacts.delete(artifact.id); + await deleteArtifactData(ctx, artifact.id); }) }, @@ -711,12 +765,24 @@ export default capsule({ html?: unknown; sharedWith?: unknown; isPublic?: unknown; + expiresInSeconds?: unknown; }>(); if (typeof body.title !== "string" || typeof body.html !== "string") { return json({ error: "title and html must be strings" }, { status: 400 }); } const title = cleanTitle(body.title); + if ( + body.expiresInSeconds !== undefined && + body.expiresInSeconds !== null && + typeof body.expiresInSeconds !== "number" + ) { + return json( + { error: "expiresInSeconds must be a number of seconds or null" }, + { status: 400 } + ); + } + await removeExpiredArtifacts(ctx as AppContext); const fallbackSlug = `${cleanSlug(title) || "artifact"}-${Date.now().toString(36)}`; const requestedSlug = typeof body.slug === "string" ? cleanSlug(body.slug) : ""; const slug = requestedSlug || fallbackSlug; @@ -740,7 +806,8 @@ export default capsule({ sharedWith, isPublic: typeof body.isPublic === "boolean" ? body.isPublic - : existing?.isPublic === true + : existing?.isPublic === true, + expiresInSeconds: body.expiresInSeconds as number | null | undefined }, `automation:${primaryOwnerEmail(ctx)}` ); diff --git a/shared/config.ts b/shared/config.ts index 943012e..cac5ab8 100644 --- a/shared/config.ts +++ b/shared/config.ts @@ -3,6 +3,9 @@ export const MAX_CHUNK_BYTES = 48 * 1024; export const MAX_TOTAL_ARTIFACT_BYTES = 768 * 1024; export const MAX_SHARED_EMAILS = 50; export const MAX_SHARED_DOMAINS = 20; +export const DEFAULT_EXPIRATION_SECONDS = 3 * 24 * 60 * 60; +export const MIN_EXPIRATION_SECONDS = 60; +export const MAX_EXPIRATION_SECONDS = 365 * 24 * 60 * 60; export function normalizeEmail(value: string): string { return value.trim().toLowerCase(); @@ -42,6 +45,35 @@ export function artifactHref(slug: string): string { return `/?artifact=${encodeURIComponent(cleanSlug(slug))}`; } +export function expirationTimestamp( + expiresInSeconds: number | null | undefined, + now = Date.now() +): string { + if (expiresInSeconds === null) { + return ""; + } + + const seconds = expiresInSeconds ?? DEFAULT_EXPIRATION_SECONDS; + if ( + !Number.isSafeInteger(seconds) || + seconds < MIN_EXPIRATION_SECONDS || + seconds > MAX_EXPIRATION_SECONDS + ) { + throw new Error( + `Expiration must be between ${MIN_EXPIRATION_SECONDS} seconds and one year, or never.` + ); + } + return new Date(now + seconds * 1000).toISOString(); +} + +export function isArtifactExpired(expiresAt: string, now = Date.now()): boolean { + if (!expiresAt) { + return false; + } + const timestamp = Date.parse(expiresAt); + return Number.isFinite(timestamp) && timestamp <= now; +} + export function parseSharedEmails(value: string | null | undefined): string[] { if (typeof value !== "string") { return []; diff --git a/skills/codex-artifacts/SKILL.md b/skills/codex-artifacts/SKILL.md index 693e963..5ba5e8a 100644 --- a/skills/codex-artifacts/SKILL.md +++ b/skills/codex-artifacts/SKILL.md @@ -30,12 +30,13 @@ When invoked in Claude Code, do not use its built-in Artifact tool. ## Publish ```sh -node "${CODEX_ARTIFACTS_SKILL_DIR:-${CODEX_HOME:-$HOME/.codex}/skills/codex-artifacts}/scripts/publish.mjs" --title "" [--slug <slug>] [--share <email,...>] [--public] +node "${CODEX_ARTIFACTS_SKILL_DIR:-${CODEX_HOME:-$HOME/.codex}/skills/codex-artifacts}/scripts/publish.mjs" <file.html> --title "<title>" [--slug <slug>] [--share <email,...>] [--expires-in <1h|3d|never>] [--public] ``` - Omit `--slug` to create; reuse a slug to update. - Use `--share` only for additional user-named recipients; on update, omit it to preserve the artifact-specific allowlist or supply it to replace that allowlist. Deployment-configured workspace viewers always retain read access. - Use `--public` only when the user explicitly requests public access. +- Omit `--expires-in` for the three-day default. Re-publishing resets the timer; pass a duration or `never` to override it. - Always let the publisher open the artifact URL in the user's default browser after success. Never pass `--no-open` unless the user explicitly asks not to open the browser. Return the URL first, access, source path, and created/updated status. diff --git a/skills/codex-artifacts/scripts/publish.mjs b/skills/codex-artifacts/scripts/publish.mjs index 41b561c..2d18677 100644 --- a/skills/codex-artifacts/scripts/publish.mjs +++ b/skills/codex-artifacts/scripts/publish.mjs @@ -16,7 +16,7 @@ const PUBLISH_TIMEOUT_MS = 30_000; function usage() { console.error(`Usage: - node publish.mjs <file.html> [--title "Title"] [--slug slug] [--share one@example.com,two@example.com] [--public] [--no-open] + node publish.mjs <file.html> [--title "Title"] [--slug slug] [--share one@example.com,two@example.com] [--expires-in 3d|never] [--public] [--no-open] Behavior: New artifacts are private by default. @@ -24,6 +24,8 @@ Behavior: --share sets additional recipients and replaces them on update. Reusing --slug updates the existing URL. Omitting --share during an update preserves the existing allowlist. + Artifacts expire in 3 days by default; every update resets that timer. + --expires-in accepts durations such as 1h, 3d, or 2w, or never. --public makes the artifact accessible without sign-in. Environment: @@ -111,6 +113,9 @@ async function main() { if (args.isPublic) { payload.isPublic = true; } + if (args.expiresInSeconds !== undefined) { + payload.expiresInSeconds = args.expiresInSeconds; + } const response = await fetch(`${baseUrl}/api/artifacts`, { method: "POST", @@ -143,6 +148,12 @@ async function main() { if (args.isPublic && body.isPublic !== true) { throw new Error("Publish succeeded, but the server did not confirm public access."); } + if (args.expiresInSeconds === null && body.expiresAt !== null) { + throw new Error("Publish succeeded, but the server did not confirm non-expiring access."); + } + if (args.expiresInSeconds !== null && typeof body.expiresAt !== "string") { + throw new Error("Publish succeeded, but the server did not confirm artifact expiration."); + } const artifactUrl = buildArtifactUrl(baseUrl, body.slug); console.log(artifactUrl); diff --git a/skills/codex-artifacts/scripts/publisher-core.mjs b/skills/codex-artifacts/scripts/publisher-core.mjs index d48eb7e..d7bdb97 100644 --- a/skills/codex-artifacts/scripts/publisher-core.mjs +++ b/skills/codex-artifacts/scripts/publisher-core.mjs @@ -1,5 +1,24 @@ -const OPTIONS_WITH_VALUES = new Set(["--title", "--slug", "--share"]); +const OPTIONS_WITH_VALUES = new Set(["--title", "--slug", "--share", "--expires-in"]); const FLAG_OPTIONS = new Set(["--public", "--no-open", "--help", "-h"]); +const MAX_EXPIRATION_SECONDS = 365 * 24 * 60 * 60; + +export function parseExpiration(value) { + const normalized = value.trim().toLowerCase(); + if (normalized === "never") { + return null; + } + + const match = normalized.match(/^(\d+)(m|h|d|w)$/); + if (!match) { + throw new Error("--expires-in must be a duration such as 1h, 3d, or 2w, or never."); + } + const units = { m: 60, h: 60 * 60, d: 24 * 60 * 60, w: 7 * 24 * 60 * 60 }; + const seconds = Number(match[1]) * units[match[2]]; + if (!Number.isSafeInteger(seconds) || seconds < 60 || seconds > MAX_EXPIRATION_SECONDS) { + throw new Error("--expires-in must be between 1m and 365d, or never."); + } + return seconds; +} export function parseArguments(args) { const result = { @@ -8,10 +27,12 @@ export function parseArguments(args) { slug: undefined, sharedWith: [], isPublic: false, + expiresInSeconds: undefined, noOpen: false, help: false }; let positionalOnly = false; + let expirationSupplied = false; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; @@ -38,6 +59,15 @@ export function parseArguments(args) { continue; } + if (argument === "--expires-in") { + if (expirationSupplied) { + throw new Error("--expires-in may only be supplied once."); + } + result.expiresInSeconds = parseExpiration(value); + expirationSupplied = true; + continue; + } + const property = argument === "--title" ? "title" : "slug"; if (result[property] !== undefined) { throw new Error(`${argument} may only be supplied once.`); diff --git a/test/publisher-core.test.mjs b/test/publisher-core.test.mjs index f127735..68356ab 100644 --- a/test/publisher-core.test.mjs +++ b/test/publisher-core.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { buildArtifactUrl, parseArguments, + parseExpiration, parseEnv, resolvePublishingProfile, validateBaseUrl @@ -28,6 +29,8 @@ test("parses publishing options and repeated recipients", () => { "--share", "three@example.com", "--public", + "--expires-in", + "1h", "--no-open" ]), { @@ -36,12 +39,21 @@ test("parses publishing options and repeated recipients", () => { slug: "architecture", sharedWith: ["one@example.com", "two@example.com", "three@example.com"], isPublic: true, + expiresInSeconds: 3600, noOpen: true, help: false } ); }); +test("parses relative and never expiration settings", () => { + assert.equal(parseExpiration("3d"), 259200); + assert.equal(parseExpiration("2w"), 1209600); + assert.equal(parseExpiration("never"), null); + assert.throws(() => parseExpiration("90 minutes"), /duration such as/); + assert.throws(() => parseExpiration("366d"), /between 1m and 365d/); +}); + test("supports an option-like filename after the option terminator", () => { assert.equal(parseArguments(["--", "--report.html"]).file, "--report.html"); }); @@ -53,6 +65,10 @@ test("rejects unknown options, duplicate scalar options, and missing values", () /only be supplied once/ ); assert.throws(() => parseArguments(["report.html", "--title", "--public"]), /requires a value/); + assert.throws( + () => parseArguments(["report.html", "--expires-in", "1h", "--expires-in", "3d"]), + /only be supplied once/ + ); }); test("parses simple quoted environment files as data", () => {