feat(share): custom /l/:alias pretty links (share_aliases table) (#205)

Add a retargetable, human-readable vanity link namespace /l/<alias> that
sits alongside the untouched /share/... routes.

- New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias),
  page_id nullable ON DELETE SET NULL so the address outlives its target).
- ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard /
  availability / request-time readable-target resolution through the single
  existing share boundary).
- Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301,
  the target is mutable) to the canonical /share/:key/p/:slug page; unknown /
  dangling / no-longer-readable aliases serve the SPA index with no leak.
  'l/:alias' excluded from the global /api prefix.
- Authenticated ShareAliasController (set/remove/availability/for-page).
- Shared ASCII-only normalize/validate util (server + client copies).
- Client: Custom address block in the share modal (live normalize + debounced
  availability + copy + reassign confirmation dialog).
- Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity
  (server jest) + client alias util (vitest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-26 06:28:26 +03:00
parent 3ddc329bba
commit fdeede003b
23 changed files with 1660 additions and 4 deletions
@@ -0,0 +1,54 @@
import { type Kysely, sql } from 'kysely';
/**
* Vanity share aliases: a retargetable, human-readable pointer (`/l/<alias>`)
* that lives independently of any single `shares` row. The alias belongs to the
* WORKSPACE (stable address), and `page_id` is nullable with ON DELETE SET NULL
* so the address survives deletion of its current target (it 404s until
* retargeted) rather than disappearing with the page.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('share_aliases')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
// Normalized ASCII, lowercase. Uniqueness is enforced per-workspace below.
.addColumn('alias', 'varchar', (col) => col.notNull())
// Nullable + SET NULL: the address outlives its target page.
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('set null'),
)
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
// The vanity name is unique within a workspace (mirrors shares.key scoping).
await db.schema
.createIndex('share_aliases_workspace_id_alias_unique')
.on('share_aliases')
.columns(['workspace_id', 'alias'])
.unique()
.execute();
// "Which alias targets this page?" lookup for the share modal.
await db.schema
.createIndex('share_aliases_page_id_idx')
.on('share_aliases')
.column('page_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('share_aliases').execute();
}
@@ -0,0 +1,99 @@
import * as migration from './20260626T130000-share-aliases';
import type {
InsertableShareAlias,
ShareAlias,
UpdatableShareAlias,
} from '../types/entity.types';
/**
* Sanity checks for the share_aliases migration + entity types. We don't run a
* live Postgres here (that's the integration suite); instead we assert the
* migration exposes the expected up/down contract and creates the table with
* the unique (workspace_id, alias) constraint and the page_id index, and that
* the generated entity types line up with the column set.
*/
describe('share-aliases migration', () => {
it('exports up and down functions', () => {
expect(typeof migration.up).toBe('function');
expect(typeof migration.down).toBe('function');
});
it('up creates the table, the unique index and the page_id index', async () => {
const calls: string[] = [];
const tableBuilder: any = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === 'execute') return async () => undefined;
// addColumn/addConstraint/etc. are chainable no-ops.
return () => tableBuilder;
},
},
);
const indexBuilder: any = new Proxy(
{},
{
get(_t, prop: string) {
if (prop === 'execute') return async () => undefined;
return () => indexBuilder;
},
},
);
const schema = {
createTable: (name: string) => {
calls.push(`createTable:${name}`);
return tableBuilder;
},
createIndex: (name: string) => {
calls.push(`createIndex:${name}`);
return indexBuilder;
},
};
await migration.up({ schema } as any);
expect(calls).toContain('createTable:share_aliases');
expect(calls).toContain(
'createIndex:share_aliases_workspace_id_alias_unique',
);
expect(calls).toContain('createIndex:share_aliases_page_id_idx');
});
it('down drops the table', async () => {
const calls: string[] = [];
const dropBuilder: any = { execute: async () => undefined };
const schema = {
dropTable: (name: string) => {
calls.push(`dropTable:${name}`);
return dropBuilder;
},
};
await migration.down({ schema } as any);
expect(calls).toContain('dropTable:share_aliases');
});
it('entity types expose the alias columns', () => {
// Compile-time + runtime sanity: a well-formed row/insert/update value.
const row: ShareAlias = {
id: 'a-1',
workspaceId: 'ws-1',
alias: 'foo',
pageId: 'p-1',
creatorId: 'u-1',
createdAt: new Date(),
updatedAt: new Date(),
};
const insert: InsertableShareAlias = {
workspaceId: 'ws-1',
alias: 'foo',
};
const update: UpdatableShareAlias = { pageId: null };
expect(row.alias).toBe('foo');
expect(insert.workspaceId).toBe('ws-1');
expect(update.pageId).toBeNull();
});
});