From a9586e97c7f42a9a221bb20adad1580e5e6c727b Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 09:51:53 +0300 Subject: [PATCH] feat: add password-protected friendly attachment links Add read-only friendly slug availability checks across local and cloud stores, allow validated password-protected aliases on link regeneration, preserve opaque-token behavior, and document/regenerate the public API SDK. Agent: aemilia --- CHANGELOG.md | 10 +++ docs/cli.md | 13 ++- sdk/src/generated.ts | 15 +++- src/cli/commands/link.test.ts | 41 +++++++++ src/cli/commands/link.ts | 22 ++++- src/cli/commands/slug.test.ts | 58 ++++++++++++ src/cli/commands/slug.ts | 37 ++++++++ src/cli/index.ts | 2 + src/core/cloud-v1.test.ts | 38 ++++++++ src/core/cloud-v1.ts | 19 +++- src/core/db.ts | 3 +- src/core/friendly-slug.test.ts | 64 +++++++++++++ src/core/friendly-slug.ts | 35 ++++++++ src/core/links.test.ts | 1 + src/core/links.ts | 3 +- src/core/store.ts | 22 ++++- src/db/pg-store.ts | 3 +- src/index.ts | 1 + src/serve/app.test.ts | 1 + src/serve/app.ts | 78 ++++++++++++++-- src/serve/friendly-slugs.test.ts | 130 +++++++++++++++++++++++++++ src/serve/openapi.ts | 42 +++++++++ src/serve/serve.test-harness.test.ts | 6 +- 23 files changed, 625 insertions(+), 19 deletions(-) create mode 100644 src/cli/commands/slug.test.ts create mode 100644 src/cli/commands/slug.ts create mode 100644 src/core/friendly-slug.test.ts create mode 100644 src/core/friendly-slug.ts create mode 100644 src/serve/friendly-slugs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 791f87f..6410e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` for a read-only availability check, then + `attachments link --regenerate --slug --password ` to + create `https:///a/`. 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 diff --git a/docs/cli.md b/docs/cli.md index f91b0f6..1230dbb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,7 +47,16 @@ Supports `--format compact|json|table`, `--expired`, `--limit ` (default ### `link ` Shows the current link. `--regenerate` accepts `--expiry`, `--password`, and -`--max-downloads`. Output supports `--format human|json` and `--brief`. +`--max-downloads`. Add `--slug --password ` to mint a +password-protected `https:///a/` alias. Friendly +slugs use lowercase letters, numbers, and single hyphens. Output supports +`--format human|json` and `--brief`. + +### `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 @@ -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" ``` diff --git a/sdk/src/generated.ts b/sdk/src/generated.ts index 4004f0c..4096459 100644 --- a/sdk/src/generated.ts +++ b/sdk/src/generated.ts @@ -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 } @@ -151,6 +153,15 @@ export class AttachmentsApiClient { }); } + /** Check whether a friendly /a/ alias is available. */ + async getFriendlySlugAvailability(slug: string, init?: RequestInit): Promise { + return this.request("GET", `/v1/slugs/${encodeURIComponent(String(slug))}`, { + body: undefined, + query: undefined, + init, + }); + } + /** Service version and mode. */ async getVersion(init?: RequestInit): Promise { return this.request("GET", `/version`, { diff --git a/src/cli/commands/link.test.ts b/src/cli/commands/link.test.ts index 6782a5b..0e3975b 100644 --- a/src/cli/commands/link.test.ts +++ b/src/cli/commands/link.test.ts @@ -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; @@ -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" })); @@ -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); diff --git a/src/cli/commands/link.ts b/src/cli/commands/link.ts index 34a309e..a900ef7 100644 --- a/src/cli/commands/link.ts +++ b/src/cli/commands/link.ts @@ -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") @@ -10,6 +11,7 @@ export function linkCommand(): Command { .option("--regenerate", "Generate a fresh share link", false) .option("--expiry