@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +204 -2
  3. package/lib/auth/grants.d.ts +16 -0
  4. package/lib/auth/grants.js +26 -0
  5. package/lib/auth/oauth.d.ts +5 -0
  6. package/lib/auth/oauth.js +6 -0
  7. package/lib/auth/token.d.ts +23 -0
  8. package/lib/auth/token.js +88 -0
  9. package/lib/capability.d.ts +19 -0
  10. package/lib/capability.js +25 -0
  11. package/lib/context.d.ts +8 -0
  12. package/lib/context.js +16 -0
  13. package/lib/did.d.ts +2 -0
  14. package/lib/did.js +8 -0
  15. package/lib/errors.d.ts +12 -0
  16. package/lib/errors.js +53 -0
  17. package/lib/handlers/body.d.ts +2 -0
  18. package/lib/handlers/body.js +3 -0
  19. package/lib/handlers/guard.d.ts +17 -0
  20. package/lib/handlers/guard.js +27 -0
  21. package/lib/handlers/index.d.ts +6 -0
  22. package/lib/handlers/index.js +153 -0
  23. package/lib/handlers/manage.d.ts +5 -0
  24. package/lib/handlers/manage.js +81 -0
  25. package/lib/handlers/read.d.ts +4 -0
  26. package/lib/handlers/read.js +118 -0
  27. package/lib/handlers/router.d.ts +31 -0
  28. package/lib/handlers/router.js +55 -0
  29. package/lib/handlers/types.d.ts +36 -0
  30. package/lib/handlers/types.js +1 -0
  31. package/lib/handlers/write.d.ts +3 -0
  32. package/lib/handlers/write.js +78 -0
  33. package/lib/index.d.ts +12 -0
  34. package/lib/index.js +11 -0
  35. package/lib/limits.d.ts +6 -0
  36. package/lib/limits.js +11 -0
  37. package/lib/plugin.d.ts +51 -0
  38. package/lib/plugin.js +190 -0
  39. package/lib/range.d.ts +11 -0
  40. package/lib/range.js +30 -0
  41. package/lib/readiness.d.ts +7 -0
  42. package/lib/readiness.js +17 -0
  43. package/lib/resources.d.ts +8 -0
  44. package/lib/resources.js +9 -0
  45. package/lib/transport.d.ts +4 -0
  46. package/lib/transport.js +15 -0
  47. package/lib/types.d.ts +34 -0
  48. package/lib/types.js +1 -0
  49. package/package.json +60 -3
package/lib/plugin.js ADDED
@@ -0,0 +1,190 @@
1
+ import { createBlobService, DEFAULT_LIMITS } from '@hozon/blob';
2
+ import { BLOB_STORE as BLOB_STORE_DEFINITION, blobStoreDefinition } from '@hozon/store-blob';
3
+ import { pluginName } from '@sozai/http-server';
4
+ import { ACCESS_GRANTS, ACCESS_REVOCATION } from '@teikyo/access';
5
+ import { HOZON_DB } from '@teikyo/hozon';
6
+ import { getAuthInfo, OAUTH_RESOURCE } from '@teikyo/oauth';
7
+ import { grantsAuthorizer } from './auth/grants.js';
8
+ import { oauthAuthenticator } from './auth/oauth.js';
9
+ import { tokenAuthenticator } from './auth/token.js';
10
+ import { createReadCapabilityMinter } from './capability.js';
11
+ import { transportFromHono } from './context.js';
12
+ import { createBlobHandlers, DEFAULT_BASE_PATH, DEFAULT_MAX_UPLOAD_SIZE, DEFAULT_UPLOAD_TIMEOUT_MS } from './handlers/index.js';
13
+ import { resolveBasePath } from './handlers/router.js';
14
+ import { assertUploadLimits } from './limits.js';
15
+ import { createReadinessCheck } from './readiness.js';
16
+ export const DEFAULT_STAGING_MAX_AGE_MS = 86400000;
17
+ export const BLOB_STORE = pluginName()('blob:store');
18
+ function isPositiveInteger(value) {
19
+ return Number.isSafeInteger(value) && value > 0;
20
+ }
21
+ /**
22
+ * Serve blob storage on `basePath`. Authenticates with kokuin invocation tokens by default (or
23
+ * OAuth bearer tokens with `oauth`) and authorizes through `access:grants` unless a custom
24
+ * `authorizer` is given.
25
+ */ export function blobPlugin(params) {
26
+ const { oauth, revocation = false } = params;
27
+ const defaultAuthenticator = params.authenticator == null && oauth == null;
28
+ if (oauth != null && params.authenticator != null) {
29
+ throw new Error('oauth cannot be combined with a custom authenticator');
30
+ }
31
+ if (defaultAuthenticator && (params.audience == null || params.audience === '')) {
32
+ throw new Error('audience is required by the default token authenticator');
33
+ }
34
+ if (revocation && !defaultAuthenticator) {
35
+ throw new Error('revocation requires the default token authenticator');
36
+ }
37
+ if (params.signer != null && !defaultAuthenticator) {
38
+ // Minted capabilities are only accepted as proofs by the default token authenticator.
39
+ throw new Error('signer requires the default token authenticator');
40
+ }
41
+ const basePath = resolveBasePath(params.basePath ?? DEFAULT_BASE_PATH);
42
+ if (basePath === '') {
43
+ throw new Error('basePath must not be the root path');
44
+ }
45
+ const maxUploadSize = params.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
46
+ const uploadTimeoutMs = params.uploadTimeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS;
47
+ assertUploadLimits({
48
+ maxUploadSize,
49
+ uploadTimeoutMs,
50
+ maxBlobSize: params.limits?.maxBlobSize ?? DEFAULT_LIMITS.maxBlobSize
51
+ });
52
+ const stagingMaxAgeMs = params.stagingMaxAgeMs ?? DEFAULT_STAGING_MAX_AGE_MS;
53
+ if (!isPositiveInteger(stagingMaxAgeMs) || stagingMaxAgeMs <= uploadTimeoutMs) {
54
+ throw new Error('stagingMaxAgeMs must be an integer above uploadTimeoutMs');
55
+ }
56
+ const dependsOn = [
57
+ HOZON_DB
58
+ ];
59
+ if (params.authorizer == null) dependsOn.push(ACCESS_GRANTS);
60
+ if (revocation) dependsOn.push(ACCESS_REVOCATION);
61
+ if (oauth != null) dependsOn.push(OAUTH_RESOURCE);
62
+ return {
63
+ name: BLOB_STORE,
64
+ dependsOn,
65
+ async setup (ctx) {
66
+ const db = ctx.use(HOZON_DB);
67
+ db.register(blobStoreDefinition);
68
+ // Run migrations now so a schema failure aborts startup.
69
+ await db.getStore(BLOB_STORE_DEFINITION);
70
+ const service = createBlobService({
71
+ db,
72
+ backend: params.backend,
73
+ codec: params.codec,
74
+ chunkSize: params.chunkSize,
75
+ lock: params.lock,
76
+ limits: params.limits
77
+ });
78
+ try {
79
+ const { removed, failed } = await service.pruneStaging(new Date(Date.now() - stagingMaxAgeMs));
80
+ if (removed > 0 || failed > 0) {
81
+ ctx.logger.info('pruned blob staging: {removed} removed, {failed} failed', {
82
+ removed,
83
+ failed
84
+ });
85
+ }
86
+ } catch (error) {
87
+ ctx.logger.error('blob staging prune failed', {
88
+ error
89
+ });
90
+ }
91
+ let authenticator;
92
+ let checkDelegation;
93
+ if (oauth != null) {
94
+ authenticator = oauthAuthenticator();
95
+ } else if (params.authenticator != null) {
96
+ // No checkDelegation: it trusts `principal.token`, so it only vouches for principals
97
+ // produced by the token authenticator. Delegated principals from here fail closed.
98
+ authenticator = params.authenticator;
99
+ } else {
100
+ const token = tokenAuthenticator({
101
+ audience: params.audience,
102
+ revocation: revocation ? ctx.use(ACCESS_REVOCATION).verifyToken : undefined,
103
+ verifyOptions: params.verifyOptions
104
+ });
105
+ authenticator = token;
106
+ checkDelegation = token.checkDelegation;
107
+ }
108
+ let authorizer;
109
+ if (params.authorizer == null) {
110
+ authorizer = grantsAuthorizer({
111
+ grants: ctx.use(ACCESS_GRANTS),
112
+ checkDelegation,
113
+ scopes: oauth?.scopes
114
+ });
115
+ } else if (checkDelegation != null) {
116
+ // `delegatedFrom` is an unverified claim until checkDelegation accepts the proof, so a
117
+ // custom authorizer only sees delegated principals whose proof covers the request.
118
+ const custom = params.authorizer;
119
+ const verifyDelegation = checkDelegation;
120
+ authorizer = async (input)=>{
121
+ const { principal, action, blobID } = input;
122
+ if (principal?.delegatedFrom != null && await verifyDelegation({
123
+ principal,
124
+ action,
125
+ blobID
126
+ }) !== true) {
127
+ return false;
128
+ }
129
+ return await custom(input);
130
+ };
131
+ } else {
132
+ authorizer = params.authorizer;
133
+ }
134
+ const handlers = createBlobHandlers({
135
+ service,
136
+ authenticator,
137
+ authorizer,
138
+ basePath,
139
+ maxUploadSize,
140
+ uploadTimeoutMs,
141
+ secureTransport: params.secureTransport,
142
+ logger: ctx.logger
143
+ });
144
+ // The handler enforces maxUploadSize while streaming and its own upload deadline.
145
+ ctx.limits(basePath, {
146
+ bodyBytes: false,
147
+ timeoutMs: false
148
+ });
149
+ if (oauth != null) {
150
+ ctx.middleware(ctx.use(OAUTH_RESOURCE).requireBearer(), basePath);
151
+ }
152
+ const handle = (c)=>{
153
+ const context = {
154
+ transport: transportFromHono(c)
155
+ };
156
+ if (oauth != null) {
157
+ const info = getAuthInfo(c);
158
+ const principal = {
159
+ subject: info.subject,
160
+ scopes: [
161
+ ...info.scopes
162
+ ]
163
+ };
164
+ context.principal = principal;
165
+ }
166
+ return handlers(c.req.raw, context);
167
+ };
168
+ ctx.route('all', basePath, handle);
169
+ ctx.route('all', `${basePath}/*`, handle);
170
+ ctx.addReadinessCheck(createReadinessCheck({
171
+ probe: ()=>service.list({
172
+ limit: 1
173
+ }),
174
+ logger: ctx.logger
175
+ }));
176
+ const api = {
177
+ service,
178
+ handlers
179
+ };
180
+ if (params.signer != null) {
181
+ api.mintReadCapability = createReadCapabilityMinter({
182
+ signer: params.signer,
183
+ serviceAudience: params.audience,
184
+ canonicalize: (id)=>service.codec.canonicalize(id)
185
+ });
186
+ }
187
+ return api;
188
+ }
189
+ };
190
+ }
package/lib/range.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** Inclusive byte range, matching HTTP `Range` / `Content-Range` semantics. */
2
+ export type ParsedRange = {
3
+ start: number;
4
+ end: number;
5
+ };
6
+ /**
7
+ * Parse a single-range HTTP `Range` header against a known content length.
8
+ * Returns `null` when the header is absent or not a `bytes` range (ignored, serve the whole body),
9
+ * a range when satisfiable, or `'unsatisfiable'` for malformed, multi-range or out-of-bounds ranges.
10
+ */
11
+ export declare function parseRange(header: string | null, length: number): ParsedRange | null | 'unsatisfiable';
package/lib/range.js ADDED
@@ -0,0 +1,30 @@
1
+ /** Inclusive byte range, matching HTTP `Range` / `Content-Range` semantics. */ /**
2
+ * Parse a single-range HTTP `Range` header against a known content length.
3
+ * Returns `null` when the header is absent or not a `bytes` range (ignored, serve the whole body),
4
+ * a range when satisfiable, or `'unsatisfiable'` for malformed, multi-range or out-of-bounds ranges.
5
+ */ export function parseRange(header, length) {
6
+ if (header == null) return null;
7
+ const value = header.trim();
8
+ if (!/^bytes\s*=/i.test(value)) return null;
9
+ const match = /^bytes\s*=\s*(\d*)-(\d*)$/i.exec(value);
10
+ if (match == null) return 'unsatisfiable';
11
+ const [, startText, endText] = match;
12
+ if (length <= 0 || startText === '' && endText === '') return 'unsatisfiable';
13
+ // Suffix form `bytes=-N`: the final N bytes.
14
+ if (startText === '') {
15
+ const suffix = Number(endText);
16
+ if (suffix === 0) return 'unsatisfiable';
17
+ return {
18
+ start: Math.max(0, length - suffix),
19
+ end: length - 1
20
+ };
21
+ }
22
+ const start = Number(startText);
23
+ if (!Number.isSafeInteger(start) || start >= length) return 'unsatisfiable';
24
+ const end = endText === '' ? length - 1 : Math.min(Number(endText), length - 1);
25
+ if (end < start) return 'unsatisfiable';
26
+ return {
27
+ start,
28
+ end
29
+ };
30
+ }
@@ -0,0 +1,7 @@
1
+ import type { Logger } from '@sozai/log';
2
+ export type ReadinessCheckParams = {
3
+ probe: () => Promise<unknown>;
4
+ logger: Logger;
5
+ };
6
+ /** Readiness check that logs on state transitions only, so a persistent outage logs once. */
7
+ export declare function createReadinessCheck(params: ReadinessCheckParams): () => Promise<boolean>;
@@ -0,0 +1,17 @@
1
+ /** Readiness check that logs on state transitions only, so a persistent outage logs once. */ export function createReadinessCheck(params) {
2
+ const { probe, logger } = params;
3
+ let healthy = true;
4
+ return async ()=>{
5
+ try {
6
+ await probe();
7
+ if (!healthy) logger.info('blob store readiness check recovered');
8
+ healthy = true;
9
+ } catch (error) {
10
+ if (healthy) logger.error('blob store readiness check failed', {
11
+ error
12
+ });
13
+ healthy = false;
14
+ }
15
+ return healthy;
16
+ };
17
+ }
@@ -0,0 +1,8 @@
1
+ import type { BlobAction } from './types.js';
2
+ /** Grant resource string; `blobID` must be the canonical ID. */
3
+ export declare function grantResource(action: BlobAction, blobID?: string): string;
4
+ /** Capability permission (`act` and `res`); `blobID` must be the canonical ID. */
5
+ export declare function capabilityPermission(action: BlobAction, blobID?: string): {
6
+ act: string;
7
+ res: string;
8
+ };
@@ -0,0 +1,9 @@
1
+ /** Grant resource string; `blobID` must be the canonical ID. */ export function grantResource(action, blobID) {
2
+ return blobID == null ? `blob/${action}` : `blob/${action}/${blobID}`;
3
+ }
4
+ /** Capability permission (`act` and `res`); `blobID` must be the canonical ID. */ export function capabilityPermission(action, blobID) {
5
+ return {
6
+ act: `blob/${action}`,
7
+ res: blobID == null ? 'blob/*' : `blob/${blobID}`
8
+ };
9
+ }
@@ -0,0 +1,4 @@
1
+ import type { BlobRequestContext } from './types.js';
2
+ export type SecureTransportOption = boolean | ((request: Request, context: BlobRequestContext) => boolean);
3
+ /** Whether the request's transport satisfies the `secureTransport` option. Fails closed. */
4
+ export declare function isTransportAllowed(option: SecureTransportOption, request: Request, context: BlobRequestContext): boolean;
@@ -0,0 +1,15 @@
1
+ function isLoopback(address) {
2
+ const addr = address.toLowerCase();
3
+ if (addr === '::1') return true;
4
+ const v4 = addr.startsWith('::ffff:') ? addr.slice(7) : addr;
5
+ // Exactly four decimal octets, 0-255, no leading zeros, first octet 127.
6
+ const octets = v4.split('.');
7
+ return octets.length === 4 && octets[0] === '127' && octets.every((octet)=>/^(0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255);
8
+ }
9
+ /** Whether the request's transport satisfies the `secureTransport` option. Fails closed. */ export function isTransportAllowed(option, request, context) {
10
+ if (typeof option === 'function') return option(request, context);
11
+ if (!option) return true;
12
+ const transport = context.transport;
13
+ if (transport == null) return false;
14
+ return transport.secure === true || isLoopback(transport.localAddress) && isLoopback(transport.remoteAddress);
15
+ }
package/lib/types.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { SignedPayload } from '@kokuin/token';
2
+ export type TokenPayload = SignedPayload;
3
+ export type BlobAction = 'read' | 'write' | 'delete' | 'pin' | 'list';
4
+ export type TransportInfo = {
5
+ /** TLS on the connection the server accepted. */
6
+ secure: boolean;
7
+ localAddress: string;
8
+ remoteAddress: string;
9
+ };
10
+ export type BlobPrincipal = {
11
+ subject: string;
12
+ /**
13
+ * Identity the caller claims to act for. A caller-supplied claim, only verified by
14
+ * `checkDelegation`: the plugin enforces it for any authorizer when the default token
15
+ * authenticator is used. Embedders calling `createBlobHandlers` with `tokenAuthenticator`
16
+ * directly must call `checkDelegation` in their authorizer.
17
+ */
18
+ delegatedFrom?: string;
19
+ token?: TokenPayload;
20
+ scopes?: Array<string>;
21
+ };
22
+ export type BlobRequestContext = {
23
+ transport?: TransportInfo;
24
+ /** Principal already verified by adapter middleware (e.g. OAuth), if any. */
25
+ principal?: BlobPrincipal;
26
+ };
27
+ export type BlobAuthenticator = (request: Request, context: BlobRequestContext) => Promise<BlobPrincipal | null>;
28
+ export type BlobAuthorizer = (input: {
29
+ action: BlobAction;
30
+ blobID?: string;
31
+ principal: BlobPrincipal | null;
32
+ request: Request;
33
+ context: BlobRequestContext;
34
+ }) => Promise<boolean>;
package/lib/types.js ADDED
@@ -0,0 +1 @@
1
+ export { };
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@teikyo/blob",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Blob storage HTTP plugin with access control",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/TairuFramework/teikyo.git",
8
+ "directory": "packages/blob"
9
+ },
10
+ "license": "MIT",
11
+ "sideEffects": false,
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./lib/index.js"
15
+ },
16
+ "main": "lib/index.js",
17
+ "types": "lib/index.d.ts",
18
+ "files": [
19
+ "lib/*"
20
+ ],
21
+ "dependencies": {
22
+ "@hozon/blob": "^0.1.1",
23
+ "@hozon/store-blob": "^0.2.2",
24
+ "@kokuin/capability": "^0.5.1",
25
+ "@kokuin/token": "^0.6.1",
26
+ "@sozai/codec": "^0.4.3",
27
+ "@sozai/http-server": "^0.2.0",
28
+ "@sozai/log": "^0.4.0",
29
+ "@teikyo/access": "^0.2.0",
30
+ "@teikyo/hozon": "^0.2.0",
31
+ "@teikyo/oauth": "^0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "@hozon/blob-backend": "^0.2.0",
35
+ "@hozon/blob-id": "^0.1.1",
36
+ "@hozon/db": "^0.3.0",
37
+ "@hozon/provider": "^0.1.2",
38
+ "@noble/curves": "^2.4.0",
39
+ "@types/node": "^26.6.5",
40
+ "del-cli": "^8.0.1",
41
+ "hono": "^4.13.13",
42
+ "vitest": "5.0.5"
43
+ },
44
+ "peerDependencies": {
45
+ "hono": "^4.13.13"
46
+ },
47
+ "engines": {
48
+ "node": ">=24"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "scripts": {
54
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
55
+ "build:clean": "del-cli lib",
56
+ "build:js": "del-cli \"lib/**/*.js\" && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
57
+ "build:types": "del-cli \"lib/**/*.d.ts\" \"lib/**/*.d.ts.map\" && tsc --emitDeclarationOnly --skipLibCheck",
58
+ "build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck --declarationMap false",
59
+ "test": "pnpm run test:types && pnpm run test:unit",
60
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
61
+ "test:unit": "vitest run"
62
+ }
6
63
  }