@hasna/shortlinks 0.1.23 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,500 @@
1
+ // @bun
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ function __accessProp(key) {
8
+ return this[key];
9
+ }
10
+ var __toESMCache_node;
11
+ var __toESMCache_esm;
12
+ var __toESM = (mod, isNodeMode, target) => {
13
+ var canCache = mod != null && typeof mod === "object";
14
+ if (canCache) {
15
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
16
+ var cached = cache.get(mod);
17
+ if (cached)
18
+ return cached;
19
+ }
20
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
21
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
22
+ for (let key of __getOwnPropNames(mod))
23
+ if (!__hasOwnProp.call(to, key))
24
+ __defProp(to, key, {
25
+ get: __accessProp.bind(mod, key),
26
+ enumerable: true
27
+ });
28
+ if (canCache)
29
+ cache.set(mod, to);
30
+ return to;
31
+ };
32
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
33
+ var __require = import.meta.require;
34
+
35
+ // src/sdk/generated.ts
36
+ class ApiError extends Error {
37
+ status;
38
+ body;
39
+ constructor(status, message, body) {
40
+ super(message);
41
+ this.status = status;
42
+ this.body = body;
43
+ this.name = "ApiError";
44
+ }
45
+ }
46
+
47
+ class ShortlinksApiClient {
48
+ baseUrl;
49
+ apiKey;
50
+ fetchImpl;
51
+ baseHeaders;
52
+ constructor(options) {
53
+ if (!options.baseUrl)
54
+ throw new Error("ShortlinksApiClient requires a baseUrl.");
55
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
56
+ this.apiKey = options.apiKey;
57
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
58
+ this.baseHeaders = options.headers ?? {};
59
+ }
60
+ async request(method, path, opts) {
61
+ const url = new URL(this.baseUrl + path);
62
+ if (opts.query) {
63
+ for (const [key, value] of Object.entries(opts.query)) {
64
+ if (value !== undefined && value !== null)
65
+ url.searchParams.set(key, String(value));
66
+ }
67
+ }
68
+ const headers = { Accept: "application/json", ...this.baseHeaders, ...opts.init?.headers };
69
+ if (this.apiKey)
70
+ headers["x-api-key"] = this.apiKey;
71
+ let payload;
72
+ if (opts.body !== undefined) {
73
+ headers["Content-Type"] = "application/json";
74
+ payload = JSON.stringify(opts.body);
75
+ }
76
+ const response = await this.fetchImpl(url.toString(), { ...opts.init, method, headers, body: payload });
77
+ const text = await response.text();
78
+ const data = text ? (() => {
79
+ try {
80
+ return JSON.parse(text);
81
+ } catch {
82
+ return text;
83
+ }
84
+ })() : undefined;
85
+ if (!response.ok) {
86
+ throw new ApiError(response.status, `${method} ${path} failed: ${response.status}`, data);
87
+ }
88
+ return data;
89
+ }
90
+ async getHealth(init) {
91
+ return this.request("GET", `/health`, {
92
+ body: undefined,
93
+ query: undefined,
94
+ init
95
+ });
96
+ }
97
+ async getReady(init) {
98
+ return this.request("GET", `/ready`, {
99
+ body: undefined,
100
+ query: undefined,
101
+ init
102
+ });
103
+ }
104
+ async listDomains(init) {
105
+ return this.request("GET", `/v1/domains`, {
106
+ body: undefined,
107
+ query: undefined,
108
+ init
109
+ });
110
+ }
111
+ async addDomain(body, init) {
112
+ return this.request("POST", `/v1/domains`, {
113
+ body,
114
+ query: undefined,
115
+ init
116
+ });
117
+ }
118
+ async listLinks(query, init) {
119
+ return this.request("GET", `/v1/links`, {
120
+ body: undefined,
121
+ query,
122
+ init
123
+ });
124
+ }
125
+ async createLink(body, init) {
126
+ return this.request("POST", `/v1/links`, {
127
+ body,
128
+ query: undefined,
129
+ init
130
+ });
131
+ }
132
+ async getLink(slug, query, init) {
133
+ return this.request("GET", `/v1/links/${encodeURIComponent(String(slug))}`, {
134
+ body: undefined,
135
+ query,
136
+ init
137
+ });
138
+ }
139
+ async deleteLink(slug, query, init) {
140
+ return this.request("DELETE", `/v1/links/${encodeURIComponent(String(slug))}`, {
141
+ body: undefined,
142
+ query,
143
+ init
144
+ });
145
+ }
146
+ async disableLink(slug, query, init) {
147
+ return this.request("POST", `/v1/links/${encodeURIComponent(String(slug))}/disable`, {
148
+ body: undefined,
149
+ query,
150
+ init
151
+ });
152
+ }
153
+ async enableLink(slug, query, init) {
154
+ return this.request("POST", `/v1/links/${encodeURIComponent(String(slug))}/enable`, {
155
+ body: undefined,
156
+ query,
157
+ init
158
+ });
159
+ }
160
+ async getLinkStats(slug, query, init) {
161
+ return this.request("GET", `/v1/links/${encodeURIComponent(String(slug))}/stats`, {
162
+ body: undefined,
163
+ query,
164
+ init
165
+ });
166
+ }
167
+ async resolveLink(slug, query, init) {
168
+ return this.request("GET", `/v1/resolve/${encodeURIComponent(String(slug))}`, {
169
+ body: undefined,
170
+ query,
171
+ init
172
+ });
173
+ }
174
+ async getStats(init) {
175
+ return this.request("GET", `/v1/stats`, {
176
+ body: undefined,
177
+ query: undefined,
178
+ init
179
+ });
180
+ }
181
+ async getVersion(init) {
182
+ return this.request("GET", `/version`, {
183
+ body: undefined,
184
+ query: undefined,
185
+ init
186
+ });
187
+ }
188
+ }
189
+ // src/serve/openapi.ts
190
+ function buildOpenApiDocument(version) {
191
+ const linkSchema = {
192
+ type: "object",
193
+ properties: {
194
+ id: { type: "string" },
195
+ domain_id: { type: "string" },
196
+ hostname: { type: "string" },
197
+ slug: { type: "string" },
198
+ destination_url: { type: "string" },
199
+ title: { type: "string", nullable: true },
200
+ active: { type: "boolean" },
201
+ expires_at: { type: "string", nullable: true },
202
+ short_url: { type: "string" },
203
+ metadata: { type: "object", additionalProperties: true },
204
+ created_at: { type: "string" },
205
+ updated_at: { type: "string" }
206
+ },
207
+ required: ["id", "domain_id", "hostname", "slug", "destination_url", "active", "created_at"]
208
+ };
209
+ const domainSchema = {
210
+ type: "object",
211
+ properties: {
212
+ id: { type: "string" },
213
+ hostname: { type: "string" },
214
+ provider: { type: "string" },
215
+ default_domain: { type: "boolean" },
216
+ origin_url: { type: "string", nullable: true },
217
+ notes: { type: "string", nullable: true },
218
+ metadata: { type: "object", additionalProperties: true },
219
+ created_at: { type: "string" },
220
+ updated_at: { type: "string" }
221
+ },
222
+ required: ["id", "hostname", "provider", "default_domain", "created_at"]
223
+ };
224
+ const linkStatsSchema = {
225
+ type: "object",
226
+ properties: {
227
+ link: { $ref: "#/components/schemas/Link" },
228
+ clicks: { type: "integer" },
229
+ last_clicked_at: { type: "string", nullable: true },
230
+ top_referrers: {
231
+ type: "array",
232
+ items: {
233
+ type: "object",
234
+ properties: { referer: { type: "string", nullable: true }, clicks: { type: "integer" } }
235
+ }
236
+ },
237
+ top_user_agents: {
238
+ type: "array",
239
+ items: {
240
+ type: "object",
241
+ properties: { user_agent: { type: "string", nullable: true }, clicks: { type: "integer" } }
242
+ }
243
+ }
244
+ },
245
+ required: ["link", "clicks"]
246
+ };
247
+ const probe = (extra = {}) => ({
248
+ type: "object",
249
+ properties: {
250
+ status: { type: "string" },
251
+ version: { type: "string" },
252
+ mode: { type: "string" },
253
+ ...extra
254
+ },
255
+ required: ["status", "version", "mode"]
256
+ });
257
+ return {
258
+ openapi: "3.0.3",
259
+ info: {
260
+ title: "ShortlinksApi",
261
+ version,
262
+ description: "Shortlink manager \u2014 custom domains, click tracking, and shortlink CRUD with API-key auth. PURE REMOTE (Amendment A1): reads/writes RDS Postgres directly."
263
+ },
264
+ servers: [{ url: "/" }],
265
+ components: {
266
+ securitySchemes: {
267
+ apiKey: { type: "apiKey", in: "header", name: "x-api-key" }
268
+ },
269
+ schemas: {
270
+ Link: linkSchema,
271
+ Domain: domainSchema,
272
+ LinkStats: linkStatsSchema,
273
+ LinkList: { type: "array", items: linkSchema },
274
+ DomainList: { type: "array", items: domainSchema },
275
+ TotalStats: {
276
+ type: "object",
277
+ properties: {
278
+ domains: { type: "integer" },
279
+ links: { type: "integer" },
280
+ clicks: { type: "integer" }
281
+ },
282
+ required: ["domains", "links", "clicks"]
283
+ },
284
+ CreateLinkRequest: {
285
+ type: "object",
286
+ properties: {
287
+ url: { type: "string", description: "Destination URL (http/https)." },
288
+ domain: { type: "string", description: "Hostname; defaults to the default domain." },
289
+ slug: { type: "string", description: "Custom slug; generated when omitted." },
290
+ title: { type: "string" },
291
+ expires_at: { type: "string", description: "ISO date/time." },
292
+ length: { type: "integer", description: "Generated slug length." },
293
+ metadata: { type: "object", additionalProperties: true }
294
+ },
295
+ required: ["url"]
296
+ },
297
+ AddDomainRequest: {
298
+ type: "object",
299
+ properties: {
300
+ hostname: { type: "string" },
301
+ provider: { type: "string" },
302
+ default: { type: "boolean" },
303
+ origin_url: { type: "string" },
304
+ notes: { type: "string" },
305
+ metadata: { type: "object", additionalProperties: true }
306
+ },
307
+ required: ["hostname"]
308
+ },
309
+ DeleteResponse: {
310
+ type: "object",
311
+ properties: { deleted: { type: "boolean" }, slug: { type: "string" } },
312
+ required: ["deleted"]
313
+ },
314
+ HealthStatus: probe({ db_latency_ms: { type: "integer" } }),
315
+ ReadyStatus: probe({ pending_migrations: { type: "array", items: { type: "string" } } }),
316
+ VersionInfo: probe({ name: { type: "string" } }),
317
+ ErrorResponse: {
318
+ type: "object",
319
+ properties: { error: { type: "string" }, reason: { type: "string" } },
320
+ required: ["error"]
321
+ }
322
+ }
323
+ },
324
+ paths: {
325
+ "/health": {
326
+ get: {
327
+ operationId: "getHealth",
328
+ summary: "Liveness probe.",
329
+ responses: {
330
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/HealthStatus" } } } }
331
+ }
332
+ }
333
+ },
334
+ "/ready": {
335
+ get: {
336
+ operationId: "getReady",
337
+ summary: "Readiness probe (DB reachable and schema migrated).",
338
+ responses: {
339
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/ReadyStatus" } } } }
340
+ }
341
+ }
342
+ },
343
+ "/version": {
344
+ get: {
345
+ operationId: "getVersion",
346
+ summary: "Service version and mode.",
347
+ responses: {
348
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/VersionInfo" } } } }
349
+ }
350
+ }
351
+ },
352
+ "/v1/stats": {
353
+ get: {
354
+ operationId: "getStats",
355
+ summary: "Total domains/links/clicks counts.",
356
+ security: [{ apiKey: [] }],
357
+ responses: {
358
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/TotalStats" } } } }
359
+ }
360
+ }
361
+ },
362
+ "/v1/domains": {
363
+ get: {
364
+ operationId: "listDomains",
365
+ summary: "List configured domains.",
366
+ security: [{ apiKey: [] }],
367
+ responses: {
368
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/DomainList" } } } }
369
+ }
370
+ },
371
+ post: {
372
+ operationId: "addDomain",
373
+ summary: "Add or update a domain.",
374
+ security: [{ apiKey: [] }],
375
+ requestBody: {
376
+ required: true,
377
+ content: { "application/json": { schema: { $ref: "#/components/schemas/AddDomainRequest" } } }
378
+ },
379
+ responses: {
380
+ "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/Domain" } } } }
381
+ }
382
+ }
383
+ },
384
+ "/v1/links": {
385
+ get: {
386
+ operationId: "listLinks",
387
+ summary: "List shortlinks.",
388
+ security: [{ apiKey: [] }],
389
+ parameters: [
390
+ { name: "domain", in: "query", schema: { type: "string" } },
391
+ { name: "active", in: "query", schema: { type: "boolean" } },
392
+ { name: "limit", in: "query", schema: { type: "integer" } }
393
+ ],
394
+ responses: {
395
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/LinkList" } } } }
396
+ }
397
+ },
398
+ post: {
399
+ operationId: "createLink",
400
+ summary: "Create a shortlink.",
401
+ security: [{ apiKey: [] }],
402
+ requestBody: {
403
+ required: true,
404
+ content: { "application/json": { schema: { $ref: "#/components/schemas/CreateLinkRequest" } } }
405
+ },
406
+ responses: {
407
+ "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/Link" } } } }
408
+ }
409
+ }
410
+ },
411
+ "/v1/links/{slug}": {
412
+ get: {
413
+ operationId: "getLink",
414
+ summary: "Get a shortlink by slug.",
415
+ security: [{ apiKey: [] }],
416
+ parameters: [
417
+ { name: "slug", in: "path", required: true, schema: { type: "string" } },
418
+ { name: "domain", in: "query", schema: { type: "string" } }
419
+ ],
420
+ responses: {
421
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Link" } } } }
422
+ }
423
+ },
424
+ delete: {
425
+ operationId: "deleteLink",
426
+ summary: "Delete a shortlink.",
427
+ security: [{ apiKey: [] }],
428
+ parameters: [
429
+ { name: "slug", in: "path", required: true, schema: { type: "string" } },
430
+ { name: "domain", in: "query", schema: { type: "string" } }
431
+ ],
432
+ responses: {
433
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteResponse" } } } }
434
+ }
435
+ }
436
+ },
437
+ "/v1/links/{slug}/enable": {
438
+ post: {
439
+ operationId: "enableLink",
440
+ summary: "Enable a shortlink.",
441
+ security: [{ apiKey: [] }],
442
+ parameters: [
443
+ { name: "slug", in: "path", required: true, schema: { type: "string" } },
444
+ { name: "domain", in: "query", schema: { type: "string" } }
445
+ ],
446
+ responses: {
447
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Link" } } } }
448
+ }
449
+ }
450
+ },
451
+ "/v1/links/{slug}/disable": {
452
+ post: {
453
+ operationId: "disableLink",
454
+ summary: "Disable a shortlink.",
455
+ security: [{ apiKey: [] }],
456
+ parameters: [
457
+ { name: "slug", in: "path", required: true, schema: { type: "string" } },
458
+ { name: "domain", in: "query", schema: { type: "string" } }
459
+ ],
460
+ responses: {
461
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Link" } } } }
462
+ }
463
+ }
464
+ },
465
+ "/v1/links/{slug}/stats": {
466
+ get: {
467
+ operationId: "getLinkStats",
468
+ summary: "Click stats for a shortlink.",
469
+ security: [{ apiKey: [] }],
470
+ parameters: [
471
+ { name: "slug", in: "path", required: true, schema: { type: "string" } },
472
+ { name: "domain", in: "query", schema: { type: "string" } }
473
+ ],
474
+ responses: {
475
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/LinkStats" } } } }
476
+ }
477
+ }
478
+ },
479
+ "/v1/resolve/{slug}": {
480
+ get: {
481
+ operationId: "resolveLink",
482
+ summary: "Resolve a slug to its destination without recording a click.",
483
+ security: [{ apiKey: [] }],
484
+ parameters: [
485
+ { name: "slug", in: "path", required: true, schema: { type: "string" } },
486
+ { name: "domain", in: "query", schema: { type: "string" } }
487
+ ],
488
+ responses: {
489
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Link" } } } }
490
+ }
491
+ }
492
+ }
493
+ }
494
+ };
495
+ }
496
+ export {
497
+ buildOpenApiDocument,
498
+ ShortlinksApiClient,
499
+ ApiError
500
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shortlinks serve HTTP app.
3
+ *
4
+ * Surfaces the standard health/ready/version probes plus a versioned `/v1`
5
+ * REST API guarded by @hasna/contracts API-key auth. PURE REMOTE (Amendment
6
+ * A1): all reads/writes go through the injected Postgres store over the shared
7
+ * RDS — there is no sync engine or cache in the service.
8
+ */
9
+ import { Hono } from "hono";
10
+ import type { PoolQueryClient } from "../generated/storage-kit/query.js";
11
+ import { PgShortlinksStore } from "../pg-store.js";
12
+ export interface ServeAppDeps {
13
+ client: PoolQueryClient;
14
+ store: PgShortlinksStore;
15
+ version: string;
16
+ mode: string;
17
+ signingSecret: string;
18
+ isRevoked?: (kid: string) => boolean | Promise<boolean>;
19
+ audit?: (event: unknown) => void;
20
+ }
21
+ export declare function createServeApp(deps: ServeAppDeps): Hono;
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `shortlinks-serve` — the cloud HTTP service entrypoint.
4
+ *
5
+ * PURE REMOTE (Amendment A1): reads/writes the shared RDS Postgres directly via
6
+ * the vendored storage kit. No sync engine, cache, or local database in the
7
+ * service.
8
+ *
9
+ * Usage:
10
+ * shortlinks-serve Run migrations (idempotent) then serve.
11
+ * shortlinks-serve migrate Run migrations and exit (one-shot task).
12
+ * shortlinks-serve --no-migrate Serve without running migrations on boot.
13
+ */
14
+ export {};