Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Friendly, password-protected share aliases are now supported end to end. Use
`attachments slug <slug>` for a read-only availability check, then
`attachments link <id> --regenerate --slug <slug> --password <password>` to
create `https://<public-host>/a/<slug>`. Slugs are stored only as hashes in
the existing share-link table, so no schema migration is required.

## [1.1.5] - 2026-07-25

### Fixed
Expand Down
13 changes: 12 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ Supports `--format compact|json|table`, `--expired`, `--limit <n>` (default
### `link <id>`

Shows the current link. `--regenerate` accepts `--expiry`, `--password`, and
`--max-downloads`. Output supports `--format human|json` and `--brief`.
`--max-downloads`. Add `--slug <friendly-slug> --password <password>` to mint a
password-protected `https://<public-host>/a/<friendly-slug>` alias. Friendly
slugs use lowercase letters, numbers, and single hyphens. Output supports
`--format human|json` and `--brief`.

### `slug <slug>`

Checks whether a friendly alias is available without creating a share link.
`--format human|json` controls output, and `--brief` prints only `available` or
`unavailable`. An unavailable valid slug exits with code `2`.

## Direct S3 Upload

Expand Down Expand Up @@ -124,4 +133,6 @@ attachments complete-task TASK-042 \
attachments snapshot-session session-id --expiry 7d --tag session:session-id
attachments report --project attachments --format markdown
attachments health-check --fix --format json
attachments slug company-closing-packet --format json
attachments link att_123 --regenerate --slug company-closing-packet --password "$ATTACHMENT_PASSWORD"
```
15 changes: 13 additions & 2 deletions sdk/src/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ export interface VersionInfo { "status": string; "version": string; "mode": stri

export interface CreateAttachmentRequest { "filename": string; "content_base64": string; "expiry"?: string; "tag"?: string; "password"?: string; "max_downloads"?: number; "link_type"?: "presigned" | "server" }

export interface LinkResponse { "link": string | null; "expires_at"?: number | null }
export interface LinkResponse { "link": string | null; "expires_at"?: number | null; "slug"?: string }

export interface RegenerateLinkRequest { "expiry"?: string; "password"?: string; "max_downloads"?: number; "link_type"?: "presigned" | "server" }
export interface RegenerateLinkRequest { "expiry"?: string; "password"?: string; "max_downloads"?: number; "link_type"?: "presigned" | "server"; "slug"?: string }

export interface SlugAvailability { "slug": string; "available": boolean }

export interface DeleteResponse { "deleted": boolean; "id": string }

Expand Down Expand Up @@ -151,6 +153,15 @@ export class AttachmentsApiClient {
});
}

/** Check whether a friendly /a/<slug> alias is available. */
async getFriendlySlugAvailability(slug: string, init?: RequestInit): Promise<SlugAvailability> {
return this.request("GET", `/v1/slugs/${encodeURIComponent(String(slug))}`, {
body: undefined,
query: undefined,
init,
});
}

/** Service version and mode. */
async getVersion(init?: RequestInit): Promise<VersionInfo> {
return this.request("GET", `/version`, {
Expand Down
41 changes: 41 additions & 0 deletions src/cli/commands/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ type MockAttachment = {
const mockFindById = mock((_id: string): MockAttachment | null => null);
const mockUpdateLink = mock((_id: string, _link: string, _expiresAt?: number | null) => {});
const mockDbClose = mock(() => {});
const mockFindShareLinkByToken = mock((_token: string) => null);
const mockCreateShareLink = mock((_input: unknown) => ({ shareLink: {}, token: "share_linktest" }));

mock.module("../../core/db", () => ({
AttachmentsDB: class MockAttachmentsDB {
constructor(_path?: string) {}
findById = mockFindById;
findShareLinkByToken = mockFindShareLinkByToken;
updateLink = mockUpdateLink;
createShareLink = mockCreateShareLink;
close = mockDbClose;
Expand Down Expand Up @@ -200,6 +202,8 @@ describe("linkCommand", () => {
mockFindById.mockReset();
mockUpdateLink.mockReset();
mockDbClose.mockReset();
mockFindShareLinkByToken.mockReset();
mockFindShareLinkByToken.mockImplementation(() => null);
mockS3Presign.mockReset();
mockCreateShareLink.mockReset();
mockCreateShareLink.mockImplementation(() => ({ shareLink: {}, token: "share_linktest" }));
Expand Down Expand Up @@ -319,6 +323,43 @@ describe("linkCommand", () => {
}
});

it("creates a password-protected friendly alias when --slug is provided", async () => {
const att = makeAttachment({ id: "att_friendly" });
mockFindById.mockImplementation(() => att);

const capture = captureOutput();
try {
const program = buildLinkCmd();
await program.parseAsync(
[
"link",
"att_friendly",
"--regenerate",
"--slug",
"company-closing-packet",
"--password",
"passphrase",
"--format",
"json",
],
{ from: "user" },
);
expect(mockFindShareLinkByToken).toHaveBeenCalledWith("company-closing-packet");
expect(mockCreateShareLink).toHaveBeenCalledWith(
expect.objectContaining({
attachmentId: "att_friendly",
token: "company-closing-packet",
password: "passphrase",
}),
);
expect(mockGeneratePresignedLink).not.toHaveBeenCalled();
const parsed = JSON.parse(capture.out.join(""));
expect(parsed.slug).toBe("company-closing-packet");
} finally {
capture.restore();
}
});

it("regenerates with default expiry when --expiry not specified", async () => {
const att = makeAttachment({ id: "att_default_expiry" });
mockFindById.mockImplementation(() => att);
Expand Down
22 changes: 20 additions & 2 deletions src/cli/commands/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from "commander";
import { getConfig } from "../../core/config";
import { resolveStore } from "../../core/store";
import { formatExpiry } from "../utils";
import { parseFriendlySlug } from "../../core/friendly-slug";

export function linkCommand(): Command {
const cmd = new Command("link")
Expand All @@ -10,6 +11,7 @@ export function linkCommand(): Command {
.option("--regenerate", "Generate a fresh share link", false)
.option("--expiry <time>", "Expiry duration for regenerated link (e.g. 7d, 24h, 30m, never)")
.option("--password <password>", "Require a password for the regenerated link")
.option("--slug <slug>", "Create a friendly /a/<slug> alias (requires --regenerate and --password)")
.option("--max-downloads <count>", "Maximum successful downloads for the regenerated link")
.option("--format <format>", "Output format: human or json", "human")
.option("--brief", "Compact one-line output")
Expand All @@ -21,6 +23,15 @@ export function linkCommand(): Command {
}

const config = getConfig();
const slug = options.slug ? parseFriendlySlug(options.slug as string) : undefined;
if (slug && !options.regenerate) {
process.stderr.write("Error: --slug requires --regenerate\n");
process.exit(1);
}
if (slug && !options.password) {
process.stderr.write("Error: --slug requires --password because friendly URLs are guessable\n");
process.exit(1);
}
const maxDownloads = options.maxDownloads ? parseInt(options.maxDownloads as string, 10) : undefined;
if (maxDownloads !== undefined && (!Number.isInteger(maxDownloads) || maxDownloads <= 0)) {
process.stderr.write("Error: --max-downloads must be a positive integer\n");
Expand All @@ -35,13 +46,14 @@ export function linkCommand(): Command {
process.exit(1);
}

let result: { link: string | null; expires_at: number | null };
let result: { link: string | null; expires_at: number | null; slug?: string };
if (options.regenerate) {
result = await store.regenerateLink(id, {
expiry: options.expiry,
password: options.password as string | undefined,
maxDownloads,
linkType: config.defaults.linkType,
slug,
});
} else {
result = await store.getLink(id);
Expand All @@ -51,7 +63,13 @@ export function linkCommand(): Command {
process.stdout.write(`${result.link ?? "no link"}\n`);
} else if (format === "json") {
process.stdout.write(
JSON.stringify({ id: att.id, filename: att.filename, link: result.link, expiresAt: result.expires_at }, null, 2) +
JSON.stringify({
id: att.id,
filename: att.filename,
link: result.link,
expiresAt: result.expires_at,
...(result.slug ? { slug: result.slug } : {}),
}, null, 2) +
"\n"
);
} else {
Expand Down
58 changes: 58 additions & 0 deletions src/cli/commands/slug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { Command } from "commander";

const mockIsSlugAvailable = mock(async (_slug: string) => true);
const mockClose = mock(() => {});

import { slugCommand } from "./slug";

const resolveStore = () => ({
isSlugAvailable: mockIsSlugAvailable,
close: mockClose,
});

describe("slug command", () => {
beforeEach(() => {
mockIsSlugAvailable.mockReset();
mockIsSlugAvailable.mockImplementation(async () => true);
mockClose.mockReset();
process.exitCode = 0;
});

test("prints JSON availability from the store", async () => {
const output: string[] = [];
const stdout = spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
output.push(String(chunk));
return true;
});
try {
const program = new Command();
program.exitOverride();
program.addCommand(slugCommand(resolveStore));
await program.parseAsync(["slug", "company-closing-packet", "--format", "json"], { from: "user" });
expect(mockIsSlugAvailable).toHaveBeenCalledWith("company-closing-packet");
expect(JSON.parse(output.join(""))).toEqual({
slug: "company-closing-packet",
available: true,
});
expect(mockClose).toHaveBeenCalled();
} finally {
stdout.mockRestore();
}
});

test("uses exit code 2 for an unavailable but valid slug", async () => {
mockIsSlugAvailable.mockImplementation(async () => false);
const stdout = spyOn(process.stdout, "write").mockImplementation(() => true);
try {
const program = new Command();
program.exitOverride();
program.addCommand(slugCommand(resolveStore));
await program.parseAsync(["slug", "company-closing-packet", "--brief"], { from: "user" });
expect(process.exitCode).toBe(2);
} finally {
process.exitCode = 0;
stdout.mockRestore();
}
});
});
37 changes: 37 additions & 0 deletions src/cli/commands/slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Command } from "commander";
import { parseFriendlySlug } from "../../core/friendly-slug";
import { resolveStore, type Store } from "../../core/store";

export function slugCommand(resolve: () => Pick<Store, "isSlugAvailable" | "close"> = resolveStore): Command {
return new Command("slug")
.description("Check whether a friendly /a/<slug> link is available")
.argument("<slug>", "Lowercase letters, numbers, and single hyphens")
.option("--format <format>", "Output format: human or json", "human")
.option("--brief", "Print only available or unavailable")
.action(async (slugInput: string, options: { format: string; brief?: boolean }) => {
if (!["human", "json"].includes(options.format)) {
process.stderr.write("Error: --format must be one of: human, json\n");
process.exitCode = 1;
return;
}

const store = resolve();
try {
const slug = parseFriendlySlug(slugInput);
const available = await store.isSlugAvailable(slug);
if (options.brief) {
process.stdout.write(`${available ? "available" : "unavailable"}\n`);
} else if (options.format === "json") {
process.stdout.write(`${JSON.stringify({ slug, available })}\n`);
} else {
process.stdout.write(`${slug}: ${available ? "available" : "unavailable"}\n`);
}
if (!available) process.exitCode = 2;
} catch (err) {
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
} finally {
store.close();
}
});
}
2 changes: 2 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { linkCommand } from "./commands/link";
import { configCommand } from "./commands/config";
import { initCommand, heartbeatCommand, focusCommand } from "./commands/agent";
import { domainCommand } from "./commands/domain";
import { slugCommand } from "./commands/slug";

// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkgVersion: string = (() => { try { return (require("../../package.json") as { version: string }).version; } catch { return process.env.npm_package_version ?? "unknown"; } })();
Expand Down Expand Up @@ -58,6 +59,7 @@ program.addCommand(removeCommand());
program.addCommand(linkCommand());
program.addCommand(configCommand());
program.addCommand(domainCommand());
program.addCommand(slugCommand());
program.addCommand(initCommand());
program.addCommand(heartbeatCommand());
program.addCommand(focusCommand());
Expand Down
38 changes: 38 additions & 0 deletions src/core/cloud-v1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,44 @@ describe("resolveAttachmentsV1", () => {
expect(calls[0]!.method).toBe("DELETE");
expect(calls[0]!.url).toBe(`${BASE}/v1/attachments/att_x`);
});

test("checks friendly slug availability through the read-only slug route", async () => {
const { calls, fetchImpl } = mockFetch(() => ({
status: 200,
body: { slug: "company-closing-packet", available: true },
}));
const r = resolveAttachmentsV1(cloudEnv, { fetchImpl });
if (r.transport !== "cloud-http") throw new Error("expected cloud");
expect(await r.store.isSlugAvailable("company-closing-packet")).toBe(true);
expect(calls[0]!.method).toBe("GET");
expect(calls[0]!.url).toBe(`${BASE}/v1/slugs/company-closing-packet`);
});

test("passes a friendly slug when regenerating a password-protected link", async () => {
const { calls, fetchImpl } = mockFetch(() => ({
status: 200,
body: {
link: "https://has.na/a/company-closing-packet",
expires_at: null,
slug: "company-closing-packet",
},
}));
const r = resolveAttachmentsV1(cloudEnv, { fetchImpl });
if (r.transport !== "cloud-http") throw new Error("expected cloud");
const result = await r.store.regenerateLink("att_1", {
slug: "company-closing-packet",
password: "passphrase",
linkType: "server",
});
expect(result.slug).toBe("company-closing-packet");
expect(calls[0]!.method).toBe("POST");
expect(calls[0]!.url).toBe(`${BASE}/v1/attachments/att_1/link`);
expect(JSON.parse(calls[0]!.body!)).toMatchObject({
slug: "company-closing-packet",
password: "passphrase",
link_type: "server",
});
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading