@pithy-sh/storage 0.1.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,140 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
+
7
+ /**
8
+ * `@pithy-sh/storage` throw sugar. The `storage/*` codes live in core's closed `KitErrorPayload` union
9
+ * (CLAUDE.md §Errors); these subclasses are the package-local vehicles that set one of those members.
10
+ * Runtime code in this package throws one of these, never a plain `new Error`.
11
+ *
12
+ * Public text goes in `message` and `action`; throw-site context goes in `detail`, which the HTTP
13
+ * codec strips. That matters more here than in most capabilities: object keys, upload ids, and byte
14
+ * counts are exactly the kind of internal shape a client should never be handed.
15
+ */
16
+
17
+ interface StorageErrorArgs {
18
+ message?: string;
19
+ action?: string;
20
+ detail?: string;
21
+ /**
22
+ * Values a translating client interpolates into its own wording for this code. Client-facing, so —
23
+ * unlike `action` and `detail` — these cross the boundary with `message`.
24
+ */
25
+ params?: MessageParams;
26
+ }
27
+
28
+ export class StorageNotFoundError extends PithyError {
29
+ constructor(args: StorageErrorArgs = {}, options?: { cause?: unknown }) {
30
+ super(
31
+ {
32
+ code: "storage/not_found",
33
+ status: 404,
34
+ message: args.message ?? "That file does not exist.",
35
+ action: args.action ?? "Check the object id. A file you do not own reads as missing, not as forbidden.",
36
+ detail: args.detail,
37
+ params: args.params,
38
+ },
39
+ options,
40
+ );
41
+ }
42
+ }
43
+
44
+ export class StorageForbiddenError extends PithyError {
45
+ constructor(args: StorageErrorArgs = {}, options?: { cause?: unknown }) {
46
+ super(
47
+ {
48
+ code: "storage/forbidden",
49
+ status: 403,
50
+ message: args.message ?? "That file is not yours.",
51
+ action: args.action ?? "Sign in as the owner, or ask them for a share link.",
52
+ detail: args.detail,
53
+ params: args.params,
54
+ },
55
+ options,
56
+ );
57
+ }
58
+ }
59
+
60
+ export class StorageQuotaExceededError extends PithyError {
61
+ constructor(args: StorageErrorArgs = {}, options?: { cause?: unknown }) {
62
+ super(
63
+ {
64
+ code: "storage/quota_exceeded",
65
+ status: 413,
66
+ message: args.message ?? "That upload would put you over your storage quota.",
67
+ action: args.action ?? "Delete something first, or raise the quota in pithy.config.ts.",
68
+ detail: args.detail,
69
+ params: args.params,
70
+ },
71
+ options,
72
+ );
73
+ }
74
+ }
75
+
76
+ export class StorageUploadIncompleteError extends PithyError {
77
+ constructor(args: StorageErrorArgs = {}, options?: { cause?: unknown }) {
78
+ super(
79
+ {
80
+ code: "storage/upload_incomplete",
81
+ status: 409,
82
+ message: args.message ?? "That file has not finished uploading.",
83
+ action: args.action ?? "Upload every part, then complete the upload.",
84
+ detail: args.detail,
85
+ params: args.params,
86
+ },
87
+ options,
88
+ );
89
+ }
90
+ }
91
+
92
+ export class StorageMultipartFailedError extends PithyError {
93
+ constructor(args: StorageErrorArgs = {}, options?: { cause?: unknown }) {
94
+ super(
95
+ {
96
+ code: "storage/multipart_failed",
97
+ status: 500,
98
+ // The recovery route is the caller's own answer, so it is in `message`: an `action` is the
99
+ // operator's sentence and the HTTP codec strips it (#344). A client that cannot see how to
100
+ // finish an upload it half-made has been told nothing useful.
101
+ message: args.message ?? "The upload could not be assembled. GET /storage/<id>/parts to see what is missing.",
102
+ detail: args.detail,
103
+ params: args.params,
104
+ },
105
+ options,
106
+ );
107
+ }
108
+ }
109
+
110
+ export class StorageShareExpiredError extends PithyError {
111
+ constructor(args: StorageErrorArgs = {}, options?: { cause?: unknown }) {
112
+ super(
113
+ {
114
+ code: "storage/share_expired",
115
+ status: 410,
116
+ message: args.message ?? "That share link has expired.",
117
+ action: args.action ?? "Ask the owner for a new one.",
118
+ detail: args.detail,
119
+ params: args.params,
120
+ },
121
+ options,
122
+ );
123
+ }
124
+ }
125
+
126
+ export class StorageShareRevokedError extends PithyError {
127
+ constructor(args: StorageErrorArgs = {}, options?: { cause?: unknown }) {
128
+ super(
129
+ {
130
+ code: "storage/share_revoked",
131
+ status: 410,
132
+ message: args.message ?? "That share link was revoked.",
133
+ action: args.action ?? "Ask the owner for a new one.",
134
+ detail: args.detail,
135
+ params: args.params,
136
+ },
137
+ options,
138
+ );
139
+ }
140
+ }
@@ -0,0 +1,32 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
5
+ import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { MiddlewareHandler } from "hono";
7
+
8
+ /**
9
+ * The storage routes' identity gate.
10
+ *
11
+ * **Copied, not imported.** This is deliberately a local copy of the same three lines
12
+ * `@pithy-sh/media` and `@pithy-sh/ledger` each carry, rather than an import from `@pithy-sh/auth`.
13
+ * Importing it would make auth a hard dependency of storage, and a hard dependency is the wrong
14
+ * shape twice over: storage works perfectly well for system-owned objects with no auth capability
15
+ * composed, and — far more importantly — a package that *imports* its authorization from another
16
+ * package fails open when that package is absent. Depending on the core `AuthContext` seam instead
17
+ * means `c.var.auth` is simply `null` with no auth capability composed, and every owner-scoped
18
+ * route **denies**. Failing closed is not a side effect here; it is the reason for the copy.
19
+ *
20
+ * So `dependsOn` stays free of auth and the manifest lists it under `optionalCapabilities`.
21
+ */
22
+ export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
23
+ return async (c, next) => {
24
+ if (!c.var.auth) {
25
+ throw new UnauthorizedError({
26
+ message: "Authentication required.",
27
+ action: "Sign in and retry with a valid session or bearer token.",
28
+ });
29
+ }
30
+ await next();
31
+ };
32
+ }