@teikyo/blob 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +204 -2
- package/lib/auth/grants.d.ts +16 -0
- package/lib/auth/grants.js +26 -0
- package/lib/auth/oauth.d.ts +5 -0
- package/lib/auth/oauth.js +6 -0
- package/lib/auth/token.d.ts +23 -0
- package/lib/auth/token.js +88 -0
- package/lib/capability.d.ts +19 -0
- package/lib/capability.js +25 -0
- package/lib/context.d.ts +8 -0
- package/lib/context.js +16 -0
- package/lib/did.d.ts +2 -0
- package/lib/did.js +8 -0
- package/lib/errors.d.ts +12 -0
- package/lib/errors.js +53 -0
- package/lib/handlers/body.d.ts +2 -0
- package/lib/handlers/body.js +3 -0
- package/lib/handlers/guard.d.ts +17 -0
- package/lib/handlers/guard.js +27 -0
- package/lib/handlers/index.d.ts +6 -0
- package/lib/handlers/index.js +153 -0
- package/lib/handlers/manage.d.ts +5 -0
- package/lib/handlers/manage.js +81 -0
- package/lib/handlers/read.d.ts +4 -0
- package/lib/handlers/read.js +118 -0
- package/lib/handlers/router.d.ts +31 -0
- package/lib/handlers/router.js +55 -0
- package/lib/handlers/types.d.ts +36 -0
- package/lib/handlers/types.js +1 -0
- package/lib/handlers/write.d.ts +3 -0
- package/lib/handlers/write.js +78 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.js +11 -0
- package/lib/limits.d.ts +6 -0
- package/lib/limits.js +11 -0
- package/lib/plugin.d.ts +51 -0
- package/lib/plugin.js +190 -0
- package/lib/range.d.ts +11 -0
- package/lib/range.js +30 -0
- package/lib/readiness.d.ts +7 -0
- package/lib/readiness.js +17 -0
- package/lib/resources.d.ts +8 -0
- package/lib/resources.js +9 -0
- package/lib/transport.d.ts +4 -0
- package/lib/transport.js +15 -0
- package/lib/types.d.ts +34 -0
- package/lib/types.js +1 -0
- package/package.json +60 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Le Cam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,205 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @teikyo/blob
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Blob storage HTTP plugin with access control, for `@sozai/http-server`. It provides the `blob:store` plugin (exported as `BLOB_STORE`), built on `@hozon/blob` (content-addressed storage with ranged reads and peer transfer) and the `hozon:db` plugin from `@teikyo/hozon`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @teikyo/blob @sozai/http-server hono
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { FSBlobBackend } from '@hozon/blob-node-fs'
|
|
15
|
+
import { createServer } from '@sozai/http-server'
|
|
16
|
+
import { accessGrantsPlugin, accessRevocationPlugin } from '@teikyo/access'
|
|
17
|
+
import { blobPlugin } from '@teikyo/blob'
|
|
18
|
+
import { hozonDBPlugin } from '@teikyo/hozon'
|
|
19
|
+
|
|
20
|
+
const server = await createServer({
|
|
21
|
+
plugins: [
|
|
22
|
+
hozonDBPlugin({ db: './app.db' }),
|
|
23
|
+
accessGrantsPlugin(),
|
|
24
|
+
accessRevocationPlugin(),
|
|
25
|
+
blobPlugin({
|
|
26
|
+
backend: new FSBlobBackend('./blobs'),
|
|
27
|
+
audience: 'did:key:z6Mk...', // this service's identifier
|
|
28
|
+
signer: serverIdentity, // optional: enables mintReadCapability
|
|
29
|
+
revocation: true, // optional: check tokens through access:revocation
|
|
30
|
+
}),
|
|
31
|
+
],
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`dependsOn` follows the configuration: `hozon:db` always, `access:grants` unless a custom `authorizer` is given, `access:revocation` with `revocation: true`, `oauth:resource` with `oauth`.
|
|
36
|
+
|
|
37
|
+
| Option | Description |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `backend` | Required `BlobBackend` (for example `FSBlobBackend`). |
|
|
40
|
+
| `codec`, `chunkSize`, `lock`, `limits` | Passed to the `@hozon/blob` service. |
|
|
41
|
+
| `basePath` | Mount path, `/blobs` by default. The root path is rejected. Each segment is limited to `A-Za-z0-9._~-`; empty, `.` and `..` segments are rejected. |
|
|
42
|
+
| `maxUploadSize` | Upload limit in bytes, 100 MiB by default. A positive integer, at most the service `maxBlobSize`. |
|
|
43
|
+
| `uploadTimeoutMs` | Upload deadline, 10 minutes by default. |
|
|
44
|
+
| `stagingMaxAgeMs` | Staging older than this is pruned at startup, 24 hours by default. Must exceed `uploadTimeoutMs`. |
|
|
45
|
+
| `audience` | This service's identifier. Required by the default token authenticator. |
|
|
46
|
+
| `verifyOptions` | kokuin verifier options (DID resolver, allowed signature methods, custom `verifiers`) for invocation tokens and their delegation proofs. |
|
|
47
|
+
| `revocation` | Check invocation tokens and their proofs through `access:revocation`. Default authenticator only. |
|
|
48
|
+
| `authenticator`, `authorizer` | Replace the defaults. With the default token authenticator, a custom `authorizer` sees delegated principals only once their proof is verified. |
|
|
49
|
+
| `oauth` | `{ scopes? }`: OAuth mode, see below. Not allowed with `authenticator`. |
|
|
50
|
+
| `signer` | Server identity. Enables `mintReadCapability`. Default token authenticator only: not allowed with `oauth` or `authenticator`. |
|
|
51
|
+
| `secureTransport` | Transport gate for read routes, `true` by default. |
|
|
52
|
+
|
|
53
|
+
The plugin API (`ctx.use(BLOB_STORE)`) exposes `service` (the `@hozon/blob` service, including the peer transfer methods), `handlers` (the framework-free request handler) and, with a `signer`, `mintReadCapability`.
|
|
54
|
+
|
|
55
|
+
## Default authentication flow
|
|
56
|
+
|
|
57
|
+
Callers send a kokuin **invocation token**: `Authorization: Bearer <token>`, signed by the caller with `aud` equal to the plugin `audience`, plus `exp` and `jti`.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const token = await identity.signToken({
|
|
61
|
+
aud: 'did:key:z6Mk...', // the plugin `audience`
|
|
62
|
+
exp: Math.floor(Date.now() / 1000) + 60,
|
|
63
|
+
jti: crypto.randomUUID(),
|
|
64
|
+
})
|
|
65
|
+
const res = await fetch('https://blobs.example.com/blobs', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { Authorization: `Bearer ${stringifyToken(token)}`, 'Content-Type': 'image/png' },
|
|
68
|
+
body: bytes,
|
|
69
|
+
})
|
|
70
|
+
// 201 { "id": "...", "contentLength": 1234, "contentType": "image/png" }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The caller (`iss`) must hold a matching grant in `access:grants`, for example `blob/*` or `blob/read/*`. A direct invocation must not carry `act`, `res` or proof claims, and a server-minted capability is never accepted on its own.
|
|
74
|
+
|
|
75
|
+
Invocation tokens are bearer credentials: anyone holding one can replay it until its `exp`. The `jti` is for revocation, not replay protection, so keep `exp` short (seconds to a minute) and send tokens over TLS only.
|
|
76
|
+
|
|
77
|
+
**Delegation.** An invocation with `sub` different from `iss` and a `cap` proof chain acts with the grants of `sub`, provided the verified capability covers the action and resource. Use `mintReadCapability({ blobID, audience, ttlSeconds = 300 })` (needs `signer`; the server identity must hold a `blob/read/*` grant) to give another identity audience-bound read access to one blob. The grantee presents it as proof inside its own invocation (`sub` = server identity, `cap` = the minted capability). `audience` must differ from both the server identity and the service `audience`. Capabilities are revocable by `jti` through `access:revocation`.
|
|
78
|
+
|
|
79
|
+
Caveats:
|
|
80
|
+
|
|
81
|
+
- Delegation (`checkDelegation`) is wired only with the default token authenticator. Delegated principals produced by a custom authenticator fail closed with the default authorizer; a custom authorizer receives them as-is, unverified.
|
|
82
|
+
- `BlobPrincipal.delegatedFrom` is a caller-supplied claim (the invocation `sub`), only verified by `checkDelegation`. With the default token authenticator the plugin enforces it for any authorizer: a custom `authorizer` only sees delegated principals whose proof covers the requested action and blob. Embedders using `createBlobHandlers` directly with `tokenAuthenticator` must call its `checkDelegation` in their authorizer before trusting `delegatedFrom`.
|
|
83
|
+
|
|
84
|
+
## OAuth mode
|
|
85
|
+
|
|
86
|
+
`blobPlugin({ backend, oauth: {} })` authenticates through `oauth:resource` (`@teikyo/oauth`) instead of kokuin tokens; `audience` is not required. The plugin mounts `requireBearer()` ahead of the blob routes. The default authorizer requires the scope for the action (`blob:<action>` by default, overridable with `oauth.scopes`, for example `{ read: 'files:read' }`) and then the usual grant check for the token subject, so a read-scoped token cannot delete even when its subject holds a delete grant. `revocation` is not available in this mode.
|
|
87
|
+
|
|
88
|
+
## Resource mapping
|
|
89
|
+
|
|
90
|
+
One mapping is shared by grants and capabilities. `<id>` is always the canonical blob ID.
|
|
91
|
+
|
|
92
|
+
| Action | Grant resource | Capability `act` | Capability `res` |
|
|
93
|
+
| --- | --- | --- | --- |
|
|
94
|
+
| read (data, meta, manifest) | `blob/read/<id>` | `blob/read` | `blob/<id>` |
|
|
95
|
+
| write with expected ID | `blob/write/<id>` | `blob/write` | `blob/<id>` |
|
|
96
|
+
| write without ID | `blob/write` | `blob/write` | `blob/*` |
|
|
97
|
+
| delete | `blob/delete/<id>` | `blob/delete` | `blob/<id>` |
|
|
98
|
+
| pin / unpin | `blob/pin/<id>` | `blob/pin` | `blob/<id>` |
|
|
99
|
+
| list | `blob/list` | `blob/list` | `blob/*` |
|
|
100
|
+
|
|
101
|
+
Grant `blob/read/*` covers reading all blobs and `blob/*` covers every blob action. `list` is a global privilege returning metadata for all blobs.
|
|
102
|
+
|
|
103
|
+
## Routes
|
|
104
|
+
|
|
105
|
+
Relative to `basePath`.
|
|
106
|
+
|
|
107
|
+
| Route | Action | Behavior |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| `POST /` | write | Raw body, `Content-Type` stored as MIME. 201 new, 200 existing, body `{ id, contentLength, contentType }`. |
|
|
110
|
+
| `PUT /:id` | write | Upload with expected ID. 422 on ID or MIME mismatch. |
|
|
111
|
+
| `GET /:id`, `HEAD /:id` | read | Single `Range` (suffix and open forms), 200/206, 416 with `Content-Range: bytes */N`. `ETag: "<id>"`, immutable `Cache-Control`, `If-None-Match` gives 304. |
|
|
112
|
+
| `GET /:id/meta` | read | `contentLength`, `contentType`, `state`, `pinned`, `createdAt`, `encrypted`, `keyID`. |
|
|
113
|
+
| `GET /:id/manifest` | read | `{ contentLength, chunkSize, chunks }` with base64url chunk digests, local blobs only. Used for peer transfer. |
|
|
114
|
+
| `GET /?limit&cursor`, `HEAD /?limit&cursor` | list | `{ entries, nextCursor }`, `limit` 1-1000 (default 100), `nextCursor` `null` on the last page. Each entry is `{ blobID, contentLength, contentType, state, pinned, createdAt, encrypted, keyID, chunkSize }`, as stored by `@hozon/store-blob` (`createdAt` in epoch milliseconds). |
|
|
115
|
+
| `DELETE /:id` | delete | 204. |
|
|
116
|
+
| `PUT /:id/pin`, `DELETE /:id/pin` | pin | 204. |
|
|
117
|
+
|
|
118
|
+
Requests are processed in this order: canonicalize the ID (400), transport gate (read, meta and manifest only), authenticate, authorize, then existence and body. Unauthorized callers get 401 (anonymous) or 403 (authenticated), never 404, so they cannot probe for IDs.
|
|
119
|
+
|
|
120
|
+
Served blobs carry `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`, because user content is served from the API origin.
|
|
121
|
+
|
|
122
|
+
Errors are JSON `{ "error": "<CODE>" }`:
|
|
123
|
+
|
|
124
|
+
| Status | Codes | When |
|
|
125
|
+
| --- | --- | --- |
|
|
126
|
+
| 400 | `INVALID_ID`, `INVALID_CONTENT_TYPE`, `INVALID_CONTENT_LENGTH`, `INVALID_LIMIT`, `INVALID_CURSOR` | Malformed ID, `Content-Type`, `Content-Length` or list query. |
|
|
127
|
+
| 401 | `UNAUTHENTICATED` | Missing or invalid credentials when anonymous access is denied; `WWW-Authenticate: Bearer`. |
|
|
128
|
+
| 403 | `FORBIDDEN`, `INSECURE_TRANSPORT` | An authenticated principal is denied (including revoked tokens), or the transport gate refuses. |
|
|
129
|
+
| 404 | `NOT_FOUND` | Unknown blob, not local, or unknown route. |
|
|
130
|
+
| 405 | `METHOD_NOT_ALLOWED` | Known route, other method; `Allow` header lists the methods. |
|
|
131
|
+
| 408 | `UPLOAD_TIMEOUT` | Upload deadline exceeded. |
|
|
132
|
+
| 413 | `TOO_LARGE` | Upload exceeds the size limit. |
|
|
133
|
+
| 416 | `RANGE_NOT_SATISFIABLE` | Unsatisfiable or multi-range `Range`. |
|
|
134
|
+
| 422 | `ID_MISMATCH`, `CONTENT_TYPE_MISMATCH` | Expected ID or MIME mismatch. |
|
|
135
|
+
| 503 | `UNAVAILABLE` | Backend or store failure, lock timeout; details are logged, never returned. |
|
|
136
|
+
|
|
137
|
+
A client disconnect during an upload produces no response; its internal `499 CLIENT_CLOSED` is for logs only.
|
|
138
|
+
|
|
139
|
+
## Transport gate
|
|
140
|
+
|
|
141
|
+
Read, meta and manifest routes require TLS on the accepted connection, or loopback on both ends. `secureTransport: false` disables the check, and a `(request, context) => boolean` predicate supports deployments behind a TLS-terminating proxy. Proxy headers are never consulted by the default gate. A missing transport context fails closed with 403 `INSECURE_TRANSPORT`.
|
|
142
|
+
|
|
143
|
+
## Limits
|
|
144
|
+
|
|
145
|
+
- `maxUploadSize` (default 100 MiB) is checked against `Content-Length` upfront and enforced while streaming (413).
|
|
146
|
+
- `uploadTimeoutMs` (default 10 minutes) aborts the upload, publishes nothing and responds 408. A client disconnect also aborts and cleans up staging.
|
|
147
|
+
- The plugin disables sozai's generic body limit and timeout on `basePath` (`ctx.limits(basePath, { bodyBytes: false, timeoutMs: false })`) because the handler enforces both itself. Downloads have no deadline.
|
|
148
|
+
- The plugin registers a readiness check that lists one entry. It logs an error when the check starts failing and an info line when it recovers, not on every failed probe.
|
|
149
|
+
- There is no automatic GC. Blobs are removed only by an authorized `DELETE`; stale staging is pruned at startup.
|
|
150
|
+
|
|
151
|
+
## Embedding in another application
|
|
152
|
+
|
|
153
|
+
`createBlobHandlers` is framework-free: it takes a `Request` and a trusted `BlobRequestContext` (never derived from request headers) and returns a `Response`. Combined with a custom codec and authorizer it integrates blobs into an application with its own access model, for example allowing reads by document reference:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { createBlobService } from '@hozon/blob'
|
|
157
|
+
import { createBlobHandlers } from '@teikyo/blob'
|
|
158
|
+
|
|
159
|
+
const service = createBlobService({ db, backend, codec: mimeEmbeddingCodec })
|
|
160
|
+
const handle = createBlobHandlers({
|
|
161
|
+
service,
|
|
162
|
+
authenticator: async (request) => myAppPrincipal(request),
|
|
163
|
+
// Deny `list`: v1 has no per-blob filtered listing.
|
|
164
|
+
authorizer: async ({ action, blobID, principal }) =>
|
|
165
|
+
action === 'list' ? false : await myAppCanAccess(principal, action, blobID),
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const response = await handle(request, {
|
|
169
|
+
transport: { secure: true, localAddress, remoteAddress },
|
|
170
|
+
})
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
When the embedded authenticator is `tokenAuthenticator`, the authorizer must check delegated principals itself, since `delegatedFrom` is unverified until then:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const tokens = tokenAuthenticator({ audience })
|
|
177
|
+
const handle = createBlobHandlers({
|
|
178
|
+
service,
|
|
179
|
+
authenticator: tokens,
|
|
180
|
+
authorizer: async ({ action, blobID, principal }) => {
|
|
181
|
+
if (principal == null) return false
|
|
182
|
+
if (principal.delegatedFrom != null && !(await tokens.checkDelegation({ principal, action, blobID }))) {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
return await myAppCanAccess(principal, action, blobID)
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
With a codec that embeds the content type in the ID, `PUT /:id` without a `Content-Type` stores the embedded type. The same handler is available from the plugin as `api.handlers`.
|
|
191
|
+
|
|
192
|
+
## Exports
|
|
193
|
+
|
|
194
|
+
- Plugin: `blobPlugin`, `BLOB_STORE`, `BlobPluginParams`, `BlobPluginAPI`, `DEFAULT_STAGING_MAX_AGE_MS`.
|
|
195
|
+
- Handlers: `createBlobHandlers`, `BlobHandler`, `BlobHandlersParams`, `DEFAULT_BASE_PATH`, `DEFAULT_MAX_UPLOAD_SIZE`, `DEFAULT_UPLOAD_TIMEOUT_MS`; range parsing `parseRange(header, length)` and `ParsedRange`.
|
|
196
|
+
- Authentication and authorization: `tokenAuthenticator`, `TokenAuthenticator`, `TokenAuthenticatorParams`, `CheckDelegation`, `CheckDelegationParams`, `oauthAuthenticator`, `requiredScope(action, scopes?)`, `grantsAuthorizer`, `GrantsAuthorizerParams`.
|
|
197
|
+
- Read capabilities: `createReadCapabilityMinter`, `ReadCapabilityMinterParams`, `MintReadCapability`, `MintReadCapabilityParams`, `DEFAULT_READ_CAPABILITY_TTL_SECONDS`.
|
|
198
|
+
- Request context and auth types: `BlobAction`, `BlobPrincipal`, `TokenPayload`, `TransportInfo`, `BlobRequestContext`, `BlobAuthenticator`, `BlobAuthorizer`.
|
|
199
|
+
- Transport: `transportFromHono(c)`, `isTransportAllowed(option, request, context)`, `SecureTransportOption`.
|
|
200
|
+
- Resource mapping: `grantResource(action, blobID?)` and `capabilityPermission(action, blobID?)`.
|
|
201
|
+
- Errors mapped to JSON responses: `errorResponse`, `toErrorResponse`, `BlobAuthenticationError` (401), `BlobAccessDeniedError` (403), `BlobInsecureTransportError` (403).
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AccessGrants } from '@teikyo/access';
|
|
2
|
+
import type { BlobAction, BlobAuthorizer } from '../types.js';
|
|
3
|
+
import type { CheckDelegation } from './token.js';
|
|
4
|
+
export type GrantsAuthorizerParams = {
|
|
5
|
+
grants: Pick<AccessGrants, 'allows'>;
|
|
6
|
+
/** Required for delegated principals; without it they are always denied. */
|
|
7
|
+
checkDelegation?: CheckDelegation;
|
|
8
|
+
/** Action to OAuth scope mapping, defaults to `blob:<action>`. Only applies to OAuth principals. */
|
|
9
|
+
scopes?: Partial<Record<BlobAction, string>>;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Default authorizer. Denies by default: anonymous, then OAuth scope (when the principal has
|
|
13
|
+
* `scopes`), then the grant for `subject` (or `delegatedFrom`), then the delegation proof.
|
|
14
|
+
* Errors from `grants.allows` propagate.
|
|
15
|
+
*/
|
|
16
|
+
export declare function grantsAuthorizer(params: GrantsAuthorizerParams): BlobAuthorizer;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { grantResource } from '../resources.js';
|
|
2
|
+
import { requiredScope } from './oauth.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default authorizer. Denies by default: anonymous, then OAuth scope (when the principal has
|
|
5
|
+
* `scopes`), then the grant for `subject` (or `delegatedFrom`), then the delegation proof.
|
|
6
|
+
* Errors from `grants.allows` propagate.
|
|
7
|
+
*/ export function grantsAuthorizer(params) {
|
|
8
|
+
const { grants, checkDelegation, scopes } = params;
|
|
9
|
+
return async ({ action, blobID, principal })=>{
|
|
10
|
+
if (principal == null) return false;
|
|
11
|
+
if (principal.scopes !== undefined && !principal.scopes.includes(requiredScope(action, scopes))) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const resource = grantResource(action, blobID);
|
|
15
|
+
if (principal.delegatedFrom == null) {
|
|
16
|
+
return await grants.allows(principal.subject, resource);
|
|
17
|
+
}
|
|
18
|
+
if (!await grants.allows(principal.delegatedFrom, resource)) return false;
|
|
19
|
+
if (checkDelegation == null) return false;
|
|
20
|
+
return await checkDelegation({
|
|
21
|
+
principal,
|
|
22
|
+
action,
|
|
23
|
+
blobID
|
|
24
|
+
}) === true;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BlobAction, BlobAuthenticator } from '../types.js';
|
|
2
|
+
/** Authenticator for OAuth mode: the principal is set on the context by adapter middleware. */
|
|
3
|
+
export declare function oauthAuthenticator(): BlobAuthenticator;
|
|
4
|
+
/** OAuth scope required for an action: the mapped scope, defaulting to `blob:<action>`. */
|
|
5
|
+
export declare function requiredScope(action: BlobAction, scopes?: Partial<Record<BlobAction, string>>): string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Authenticator for OAuth mode: the principal is set on the context by adapter middleware. */ export function oauthAuthenticator() {
|
|
2
|
+
return async (_request, context)=>context.principal ?? null;
|
|
3
|
+
}
|
|
4
|
+
/** OAuth scope required for an action: the mapped scope, defaulting to `blob:<action>`. */ export function requiredScope(action, scopes) {
|
|
5
|
+
return scopes?.[action] ?? `blob:${action}`;
|
|
6
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type SignedToken, type VerifyTokenOptions } from '@kokuin/token';
|
|
2
|
+
import type { BlobAction, BlobAuthenticator, BlobPrincipal } from '../types.js';
|
|
3
|
+
export type TokenAuthenticatorParams = {
|
|
4
|
+
/** This service's identifier; invocation tokens must carry it as `aud`. */
|
|
5
|
+
audience: string;
|
|
6
|
+
/** Revocation hook, run on the invocation and on each delegation proof. */
|
|
7
|
+
revocation?: (token: SignedToken, raw: string) => Promise<void>;
|
|
8
|
+
verifyOptions?: Pick<VerifyTokenOptions, 'resolver' | 'cache' | 'methods' | 'verifiers'>;
|
|
9
|
+
};
|
|
10
|
+
export type CheckDelegationParams = {
|
|
11
|
+
principal: BlobPrincipal;
|
|
12
|
+
action: BlobAction;
|
|
13
|
+
blobID?: string;
|
|
14
|
+
};
|
|
15
|
+
export type CheckDelegation = (params: CheckDelegationParams) => Promise<boolean>;
|
|
16
|
+
export type TokenAuthenticator = BlobAuthenticator & {
|
|
17
|
+
/**
|
|
18
|
+
* Whether the principal's verified delegation proof covers `action` on `blobID`. The principal
|
|
19
|
+
* must come from this authenticator; mismatched subject/delegatedFrom yield false.
|
|
20
|
+
*/
|
|
21
|
+
checkDelegation: CheckDelegation;
|
|
22
|
+
};
|
|
23
|
+
export declare function tokenAuthenticator(params: TokenAuthenticatorParams): TokenAuthenticator;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { checkCapability } from '@kokuin/capability';
|
|
2
|
+
import { verifyToken } from '@kokuin/token';
|
|
3
|
+
import { sameDID } from '../did.js';
|
|
4
|
+
import { BlobAccessDeniedError, BlobAuthenticationError } from '../errors.js';
|
|
5
|
+
import { capabilityPermission } from '../resources.js';
|
|
6
|
+
const BEARER = /^Bearer[ \t]+(\S+)[ \t]*$/i;
|
|
7
|
+
export function tokenAuthenticator(params) {
|
|
8
|
+
const { audience, revocation, verifyOptions = {} } = params;
|
|
9
|
+
const { resolver, cache, methods, verifiers } = verifyOptions;
|
|
10
|
+
const authenticate = async (request)=>{
|
|
11
|
+
const header = request.headers.get('Authorization');
|
|
12
|
+
if (header == null) return null;
|
|
13
|
+
const match = BEARER.exec(header);
|
|
14
|
+
if (match == null) throw new BlobAuthenticationError();
|
|
15
|
+
const raw = match[1] ?? '';
|
|
16
|
+
let verified;
|
|
17
|
+
try {
|
|
18
|
+
verified = await verifyToken(raw, {
|
|
19
|
+
resolver,
|
|
20
|
+
cache,
|
|
21
|
+
methods,
|
|
22
|
+
verifiers
|
|
23
|
+
});
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
throw new BlobAuthenticationError('Invalid token', {
|
|
26
|
+
cause
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const payload = verified.payload;
|
|
30
|
+
if (typeof payload.iss !== 'string') throw new BlobAuthenticationError();
|
|
31
|
+
if (typeof payload.aud !== 'string' || !sameDID(payload.aud, audience)) {
|
|
32
|
+
throw new BlobAuthenticationError('Invalid audience');
|
|
33
|
+
}
|
|
34
|
+
if (typeof payload.exp !== 'number') throw new BlobAuthenticationError('Missing expiry');
|
|
35
|
+
if (typeof payload.jti !== 'string' || payload.jti === '') {
|
|
36
|
+
throw new BlobAuthenticationError('Missing jti');
|
|
37
|
+
}
|
|
38
|
+
const delegated = payload.sub != null && !sameDID(payload.sub, payload.iss);
|
|
39
|
+
if (delegated) {
|
|
40
|
+
if (typeof payload.sub !== 'string' || payload.cap == null) {
|
|
41
|
+
throw new BlobAuthenticationError('Missing proof');
|
|
42
|
+
}
|
|
43
|
+
if (payload.act != null || payload.res != null) {
|
|
44
|
+
throw new BlobAuthenticationError('Capability presented as invocation');
|
|
45
|
+
}
|
|
46
|
+
} else if (payload.act != null || payload.res != null || payload.cap != null) {
|
|
47
|
+
// Capability-shaped: a capability is only valid as proof inside an invocation.
|
|
48
|
+
throw new BlobAuthenticationError('Capability presented as invocation');
|
|
49
|
+
}
|
|
50
|
+
if (revocation != null) {
|
|
51
|
+
// Any throw from the hook (revoked or lookup failure) maps to 403 BlobAccessDeniedError.
|
|
52
|
+
try {
|
|
53
|
+
await revocation(verified, raw);
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
throw new BlobAccessDeniedError('Token revoked', {
|
|
56
|
+
cause
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return delegated ? {
|
|
61
|
+
subject: payload.iss,
|
|
62
|
+
delegatedFrom: payload.sub,
|
|
63
|
+
token: payload
|
|
64
|
+
} : {
|
|
65
|
+
subject: payload.iss
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const checkDelegation = async ({ principal, action, blobID })=>{
|
|
69
|
+
const { token, delegatedFrom } = principal;
|
|
70
|
+
if (token == null || delegatedFrom == null || typeof token.sub !== 'string') return false;
|
|
71
|
+
if (!sameDID(delegatedFrom, token.sub) || !sameDID(principal.subject, token.iss)) return false;
|
|
72
|
+
try {
|
|
73
|
+
await checkCapability(capabilityPermission(action, blobID), token, {
|
|
74
|
+
verifyToken: revocation,
|
|
75
|
+
resolver,
|
|
76
|
+
cache,
|
|
77
|
+
methods,
|
|
78
|
+
verifiers
|
|
79
|
+
});
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
return Object.assign(authenticate, {
|
|
86
|
+
checkDelegation
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type SigningIdentity } from '@kokuin/token';
|
|
2
|
+
export declare const DEFAULT_READ_CAPABILITY_TTL_SECONDS = 300;
|
|
3
|
+
export type ReadCapabilityMinterParams = {
|
|
4
|
+
/** Server identity; issuer and subject of minted capabilities. */
|
|
5
|
+
signer: SigningIdentity;
|
|
6
|
+
/** This service's identifier; never a valid capability audience. */
|
|
7
|
+
serviceAudience?: string;
|
|
8
|
+
/** Maps a blob ID to its canonical form. */
|
|
9
|
+
canonicalize: (id: string) => string;
|
|
10
|
+
};
|
|
11
|
+
export type MintReadCapabilityParams = {
|
|
12
|
+
blobID: string;
|
|
13
|
+
/** Grantee DID. */
|
|
14
|
+
audience: string;
|
|
15
|
+
/** Integer in 1-86400, default 300. */
|
|
16
|
+
ttlSeconds?: number;
|
|
17
|
+
};
|
|
18
|
+
export type MintReadCapability = (params: MintReadCapabilityParams) => Promise<string>;
|
|
19
|
+
export declare function createReadCapabilityMinter(params: ReadCapabilityMinterParams): MintReadCapability;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createCapability } from '@kokuin/capability';
|
|
2
|
+
import { stringifyToken } from '@kokuin/token';
|
|
3
|
+
import { sameDID } from './did.js';
|
|
4
|
+
import { capabilityPermission } from './resources.js';
|
|
5
|
+
export const DEFAULT_READ_CAPABILITY_TTL_SECONDS = 300;
|
|
6
|
+
const MAX_TTL_SECONDS = 86400;
|
|
7
|
+
export function createReadCapabilityMinter(params) {
|
|
8
|
+
const { signer, serviceAudience, canonicalize } = params;
|
|
9
|
+
return async ({ blobID, audience, ttlSeconds = DEFAULT_READ_CAPABILITY_TTL_SECONDS })=>{
|
|
10
|
+
if (sameDID(audience, signer.id) || serviceAudience != null && sameDID(audience, serviceAudience)) {
|
|
11
|
+
throw new Error('Invalid audience');
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_TTL_SECONDS) {
|
|
14
|
+
throw new Error('Invalid ttlSeconds');
|
|
15
|
+
}
|
|
16
|
+
const token = await createCapability(signer, {
|
|
17
|
+
sub: signer.id,
|
|
18
|
+
aud: audience,
|
|
19
|
+
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
|
20
|
+
jti: crypto.randomUUID(),
|
|
21
|
+
...capabilityPermission('read', canonicalize(blobID))
|
|
22
|
+
});
|
|
23
|
+
return stringifyToken(token);
|
|
24
|
+
};
|
|
25
|
+
}
|
package/lib/context.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { TransportInfo } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Transport info of the connection the Node server accepted, read from the socket behind
|
|
5
|
+
* `c.env.incoming` (`@hono/node-server`). Never consults request headers. `undefined` when the
|
|
6
|
+
* request has no Node socket (e.g. `app.request()`), which the default transport gate rejects.
|
|
7
|
+
*/
|
|
8
|
+
export declare function transportFromHono(c: Context): TransportInfo | undefined;
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport info of the connection the Node server accepted, read from the socket behind
|
|
3
|
+
* `c.env.incoming` (`@hono/node-server`). Never consults request headers. `undefined` when the
|
|
4
|
+
* request has no Node socket (e.g. `app.request()`), which the default transport gate rejects.
|
|
5
|
+
*/ export function transportFromHono(c) {
|
|
6
|
+
const env = c.env;
|
|
7
|
+
const socket = env?.incoming?.socket;
|
|
8
|
+
if (socket == null) return undefined;
|
|
9
|
+
const { encrypted, localAddress, remoteAddress } = socket;
|
|
10
|
+
if (typeof localAddress !== 'string' || typeof remoteAddress !== 'string') return undefined;
|
|
11
|
+
return {
|
|
12
|
+
secure: encrypted === true,
|
|
13
|
+
localAddress,
|
|
14
|
+
remoteAddress
|
|
15
|
+
};
|
|
16
|
+
}
|
package/lib/did.d.ts
ADDED
package/lib/did.js
ADDED
package/lib/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Logger } from '@sozai/log';
|
|
2
|
+
export declare class BlobAuthenticationError extends Error {
|
|
3
|
+
constructor(message?: string, options?: ErrorOptions);
|
|
4
|
+
}
|
|
5
|
+
export declare class BlobAccessDeniedError extends Error {
|
|
6
|
+
constructor(message?: string, options?: ErrorOptions);
|
|
7
|
+
}
|
|
8
|
+
export declare class BlobInsecureTransportError extends Error {
|
|
9
|
+
constructor(message?: string, options?: ErrorOptions);
|
|
10
|
+
}
|
|
11
|
+
export declare function errorResponse(status: number, code: string, headers?: HeadersInit): Response;
|
|
12
|
+
export declare function toErrorResponse(error: unknown, logger: Logger): Response;
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { BlobIDMismatchError, BlobLockTimeoutError, BlobNotFoundError, BlobTooLargeError, ContentTypeMismatchError, InvalidBlobIDError, InvalidRangeError } from '@hozon/blob';
|
|
2
|
+
export class BlobAuthenticationError extends Error {
|
|
3
|
+
constructor(message = 'Authentication failed', options){
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.name = 'BlobAuthenticationError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export class BlobAccessDeniedError extends Error {
|
|
9
|
+
constructor(message = 'Access denied', options){
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = 'BlobAccessDeniedError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class BlobInsecureTransportError extends Error {
|
|
15
|
+
constructor(message = 'Insecure transport', options){
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = 'BlobInsecureTransportError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function errorResponse(status, code, headers) {
|
|
21
|
+
const responseHeaders = new Headers(headers);
|
|
22
|
+
responseHeaders.set('Content-Type', 'application/json');
|
|
23
|
+
return new Response(JSON.stringify({
|
|
24
|
+
error: code
|
|
25
|
+
}), {
|
|
26
|
+
status,
|
|
27
|
+
headers: responseHeaders
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function toErrorResponse(error, logger) {
|
|
31
|
+
if (error instanceof InvalidBlobIDError) return errorResponse(400, 'INVALID_ID');
|
|
32
|
+
if (error instanceof InvalidRangeError) return errorResponse(416, 'RANGE_NOT_SATISFIABLE');
|
|
33
|
+
if (error instanceof BlobNotFoundError) return errorResponse(404, 'NOT_FOUND');
|
|
34
|
+
if (error instanceof BlobTooLargeError) return errorResponse(413, 'TOO_LARGE');
|
|
35
|
+
if (error instanceof BlobIDMismatchError) return errorResponse(422, 'ID_MISMATCH');
|
|
36
|
+
if (error instanceof ContentTypeMismatchError) {
|
|
37
|
+
return errorResponse(422, 'CONTENT_TYPE_MISMATCH');
|
|
38
|
+
}
|
|
39
|
+
if (error instanceof BlobAuthenticationError) {
|
|
40
|
+
return errorResponse(401, 'UNAUTHENTICATED', {
|
|
41
|
+
'WWW-Authenticate': 'Bearer'
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (error instanceof BlobInsecureTransportError) {
|
|
45
|
+
return errorResponse(403, 'INSECURE_TRANSPORT');
|
|
46
|
+
}
|
|
47
|
+
if (error instanceof BlobAccessDeniedError) return errorResponse(403, 'FORBIDDEN');
|
|
48
|
+
// BlobLockTimeoutError and anything unknown: masked from the client, logged.
|
|
49
|
+
logger.error(error instanceof BlobLockTimeoutError ? 'Blob lock acquire timed out: {error}' : 'Blob request failed: {error}', {
|
|
50
|
+
error
|
|
51
|
+
});
|
|
52
|
+
return errorResponse(503, 'UNAVAILABLE');
|
|
53
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BlobAction, BlobPrincipal, BlobRequestContext } from '../types.js';
|
|
2
|
+
import type { ResolvedBlobHandlersParams } from './types.js';
|
|
3
|
+
export type GuardParams = Pick<ResolvedBlobHandlersParams, 'authenticator' | 'authorizer' | 'secureTransport'>;
|
|
4
|
+
export type AuthorizeParams = {
|
|
5
|
+
guard: GuardParams;
|
|
6
|
+
request: Request;
|
|
7
|
+
context: BlobRequestContext;
|
|
8
|
+
action: BlobAction;
|
|
9
|
+
blobID?: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Request order after ID canonicalization: transport gate (read only) -> authenticate -> authorize.
|
|
13
|
+
* Throws `BlobInsecureTransportError` (transport gate), `BlobAuthenticationError` (invalid
|
|
14
|
+
* credentials from the authenticator, or a denied anonymous caller) or `BlobAccessDeniedError`
|
|
15
|
+
* (denied principal). Errors thrown by the predicate, authenticator or authorizer propagate.
|
|
16
|
+
*/
|
|
17
|
+
export declare function authorize(params: AuthorizeParams): Promise<BlobPrincipal | null>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { BlobAccessDeniedError, BlobAuthenticationError, BlobInsecureTransportError } from '../errors.js';
|
|
2
|
+
import { isTransportAllowed } from '../transport.js';
|
|
3
|
+
/**
|
|
4
|
+
* Request order after ID canonicalization: transport gate (read only) -> authenticate -> authorize.
|
|
5
|
+
* Throws `BlobInsecureTransportError` (transport gate), `BlobAuthenticationError` (invalid
|
|
6
|
+
* credentials from the authenticator, or a denied anonymous caller) or `BlobAccessDeniedError`
|
|
7
|
+
* (denied principal). Errors thrown by the predicate, authenticator or authorizer propagate.
|
|
8
|
+
*/ export async function authorize(params) {
|
|
9
|
+
const { guard, request, context, action, blobID } = params;
|
|
10
|
+
if (action === 'read' && !isTransportAllowed(guard.secureTransport, request, context)) {
|
|
11
|
+
throw new BlobInsecureTransportError();
|
|
12
|
+
}
|
|
13
|
+
const principal = await guard.authenticator(request, context);
|
|
14
|
+
const allowed = await guard.authorizer({
|
|
15
|
+
action,
|
|
16
|
+
blobID,
|
|
17
|
+
principal,
|
|
18
|
+
request,
|
|
19
|
+
context
|
|
20
|
+
});
|
|
21
|
+
if (!allowed) {
|
|
22
|
+
// Anonymous callers are asked to authenticate (401); known principals are refused (403).
|
|
23
|
+
if (principal == null) throw new BlobAuthenticationError('Authentication required');
|
|
24
|
+
throw new BlobAccessDeniedError();
|
|
25
|
+
}
|
|
26
|
+
return principal;
|
|
27
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BlobHandler, BlobHandlersParams } from './types.js';
|
|
2
|
+
export type { BlobHandler, BlobHandlersParams } from './types.js';
|
|
3
|
+
export declare const DEFAULT_BASE_PATH = "/blobs";
|
|
4
|
+
export declare const DEFAULT_MAX_UPLOAD_SIZE = 104857600;
|
|
5
|
+
export declare const DEFAULT_UPLOAD_TIMEOUT_MS = 600000;
|
|
6
|
+
export declare function createBlobHandlers(params: BlobHandlersParams): BlobHandler;
|