@keetanetwork/anchor 0.0.39 → 0.0.41
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/client/index.d.ts +6 -0
- package/client/index.d.ts.map +1 -1
- package/client/index.js +7 -0
- package/client/index.js.map +1 -1
- package/lib/block-listener.d.ts +93 -0
- package/lib/block-listener.d.ts.map +1 -0
- package/lib/block-listener.js +259 -0
- package/lib/block-listener.js.map +1 -0
- package/lib/error.d.ts.map +1 -1
- package/lib/error.js +3 -1
- package/lib/error.js.map +1 -1
- package/lib/http-server/index.d.ts +14 -1
- package/lib/http-server/index.d.ts.map +1 -1
- package/lib/http-server/index.js +86 -7
- package/lib/http-server/index.js.map +1 -1
- package/lib/queue/index.d.ts +20 -5
- package/lib/queue/index.d.ts.map +1 -1
- package/lib/queue/index.js +52 -17
- package/lib/queue/index.js.map +1 -1
- package/lib/resolver.d.ts +57 -0
- package/lib/resolver.d.ts.map +1 -1
- package/lib/resolver.js +864 -250
- package/lib/resolver.js.map +1 -1
- package/npm-shrinkwrap.json +4 -4
- package/package.json +1 -1
- package/services/asset-movement/client.d.ts +9 -2
- package/services/asset-movement/client.d.ts.map +1 -1
- package/services/asset-movement/client.js +35 -2
- package/services/asset-movement/client.js.map +1 -1
- package/services/asset-movement/common.d.ts +1 -0
- package/services/asset-movement/common.d.ts.map +1 -1
- package/services/asset-movement/common.js +75 -0
- package/services/asset-movement/common.js.map +1 -1
- package/services/asset-movement/server.d.ts +0 -10
- package/services/asset-movement/server.d.ts.map +1 -1
- package/services/asset-movement/server.js +0 -2
- package/services/asset-movement/server.js.map +1 -1
- package/services/fx/common.d.ts +1 -1
- package/services/fx/common.js.map +1 -1
- package/services/fx/server.d.ts +37 -6
- package/services/fx/server.d.ts.map +1 -1
- package/services/fx/server.js +207 -66
- package/services/fx/server.js.map +1 -1
- package/services/storage/client.d.ts +332 -0
- package/services/storage/client.d.ts.map +1 -0
- package/services/storage/client.js +1078 -0
- package/services/storage/client.js.map +1 -0
- package/services/storage/clients/contacts.d.ts +94 -0
- package/services/storage/clients/contacts.d.ts.map +1 -0
- package/services/storage/clients/contacts.generated.d.ts +3 -0
- package/services/storage/clients/contacts.generated.d.ts.map +1 -0
- package/services/storage/clients/contacts.generated.js +1197 -0
- package/services/storage/clients/contacts.generated.js.map +1 -0
- package/services/storage/clients/contacts.js +141 -0
- package/services/storage/clients/contacts.js.map +1 -0
- package/services/storage/common.d.ts +667 -0
- package/services/storage/common.d.ts.map +1 -0
- package/services/storage/common.generated.d.ts +17 -0
- package/services/storage/common.generated.d.ts.map +1 -0
- package/services/storage/common.generated.js +863 -0
- package/services/storage/common.generated.js.map +1 -0
- package/services/storage/common.js +587 -0
- package/services/storage/common.js.map +1 -0
- package/services/storage/lib/validators.d.ts +64 -0
- package/services/storage/lib/validators.d.ts.map +1 -0
- package/services/storage/lib/validators.js +82 -0
- package/services/storage/lib/validators.js.map +1 -0
- package/services/storage/server.d.ts +127 -0
- package/services/storage/server.d.ts.map +1 -0
- package/services/storage/server.js +626 -0
- package/services/storage/server.js.map +1 -0
- package/services/storage/test-utils.d.ts +70 -0
- package/services/storage/test-utils.d.ts.map +1 -0
- package/services/storage/test-utils.js +347 -0
- package/services/storage/test-utils.js.map +1 -0
- package/services/storage/utils.d.ts +3 -0
- package/services/storage/utils.d.ts.map +1 -0
- package/services/storage/utils.js +10 -0
- package/services/storage/utils.js.map +1 -0
- package/services/username/client.d.ts +145 -0
- package/services/username/client.d.ts.map +1 -0
- package/services/username/client.js +681 -0
- package/services/username/client.js.map +1 -0
- package/services/username/common.d.ts +136 -0
- package/services/username/common.d.ts.map +1 -0
- package/services/username/common.generated.d.ts +13 -0
- package/services/username/common.generated.d.ts.map +1 -0
- package/services/username/common.generated.js +256 -0
- package/services/username/common.generated.js.map +1 -0
- package/services/username/common.js +226 -0
- package/services/username/common.js.map +1 -0
- package/services/username/server.d.ts +49 -0
- package/services/username/server.d.ts.map +1 -0
- package/services/username/server.js +262 -0
- package/services/username/server.js.map +1 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import * as KeetaAnchorHTTPServer from '../../lib/http-server/index.js';
|
|
2
|
+
import { KeetaNet } from '../../client/index.js';
|
|
3
|
+
import { KeetaAnchorUserError } from '../../lib/error.js';
|
|
4
|
+
import { assertKeetaStorageAnchorDeleteResponse, assertKeetaStorageAnchorPutResponse, assertKeetaStorageAnchorGetRequest, assertKeetaStorageAnchorSearchRequest, assertKeetaStorageAnchorSearchResponse, assertKeetaStorageAnchorQuotaResponse } from './common.generated.js';
|
|
5
|
+
import { getKeetaStorageAnchorDeleteRequestSigningData, getKeetaStorageAnchorPutRequestSigningData, getKeetaStorageAnchorGetRequestSigningData, getKeetaStorageAnchorSearchRequestSigningData, getKeetaStorageAnchorQuotaRequestSigningData, parseContainerPayload, Errors, CONTENT_TYPE_OCTET_STREAM, DEFAULT_SIGNED_URL_TTL_SECONDS } from './common.js';
|
|
6
|
+
import { VerifySignedData } from '../../lib/utils/signing.js';
|
|
7
|
+
import { assertHTTPSignedField, parseSignatureFromURL } from '../../lib/http-server/common.js';
|
|
8
|
+
import { arrayBufferLikeToBuffer, Buffer } from '../../lib/utils/buffer.js';
|
|
9
|
+
import { requiresValidation, findMatchingValidators } from './lib/validators.js';
|
|
10
|
+
import { EncryptedContainer, EncryptedContainerError } from '../../lib/encrypted-container.js';
|
|
11
|
+
import { assertVisibility } from './utils.js';
|
|
12
|
+
/**
|
|
13
|
+
* Build a standardized search response from search results.
|
|
14
|
+
*/
|
|
15
|
+
function buildSearchResponse(results) {
|
|
16
|
+
const response = {
|
|
17
|
+
ok: true,
|
|
18
|
+
results: results.results
|
|
19
|
+
};
|
|
20
|
+
if (results.nextCursor !== undefined) {
|
|
21
|
+
response.nextCursor = results.nextCursor;
|
|
22
|
+
}
|
|
23
|
+
return (response);
|
|
24
|
+
}
|
|
25
|
+
// #region Module-Level Helpers
|
|
26
|
+
/**
|
|
27
|
+
* Find a matching policy for a path, validate it, and check access.
|
|
28
|
+
*
|
|
29
|
+
* @param pathPolicies - Array of path policies to check against
|
|
30
|
+
* @param account - The account to check access for
|
|
31
|
+
* @param path - The path to check
|
|
32
|
+
* @param operation - The operation being performed
|
|
33
|
+
*/
|
|
34
|
+
function assertPathAccess(pathPolicies, account, path, operation) {
|
|
35
|
+
for (const policy of pathPolicies) {
|
|
36
|
+
const parsed = policy.parse(path);
|
|
37
|
+
if (parsed !== null) {
|
|
38
|
+
policy.validate(path);
|
|
39
|
+
if (!policy.checkAccess(account, parsed, operation)) {
|
|
40
|
+
throw (new Errors.AccessDenied('Can only access your own namespace'));
|
|
41
|
+
}
|
|
42
|
+
return ({ policy, parsed });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw (new Errors.InvalidPath('Path does not match any policy'));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Find a matching policy and parse a path.
|
|
49
|
+
* Used for public endpoints where auth is optional.
|
|
50
|
+
*
|
|
51
|
+
* @param pathPolicies - Array of path policies to check against
|
|
52
|
+
* @param path - The path to parse
|
|
53
|
+
*/
|
|
54
|
+
function parsePath(pathPolicies, path) {
|
|
55
|
+
for (const policy of pathPolicies) {
|
|
56
|
+
const parsed = policy.parse(path);
|
|
57
|
+
if (parsed !== null) {
|
|
58
|
+
policy.validate(path);
|
|
59
|
+
return ({ policy, parsed });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw (new Errors.InvalidPath('Path does not match any policy'));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Verify a signed request from POST body.
|
|
66
|
+
* Extracts account and signature from the request, verifies the signature,
|
|
67
|
+
* and returns the authenticated account.
|
|
68
|
+
*
|
|
69
|
+
* @typeParam T - Request type containing optional account and signed fields
|
|
70
|
+
*
|
|
71
|
+
* @param request - The request object containing account and signed fields
|
|
72
|
+
* @param getSigningData - Function to extract signable data from the request
|
|
73
|
+
*
|
|
74
|
+
* @returns The authenticated account
|
|
75
|
+
*
|
|
76
|
+
* @throws KeetaAnchorUserError if authentication is missing or invalid
|
|
77
|
+
*/
|
|
78
|
+
async function verifyBodyAuth(request, getSigningData) {
|
|
79
|
+
if (!request.account || !request.signed) {
|
|
80
|
+
throw (new KeetaAnchorUserError('Authentication required'));
|
|
81
|
+
}
|
|
82
|
+
const account = KeetaNet.lib.Account.fromPublicKeyString(request.account).assertAccount();
|
|
83
|
+
const signable = getSigningData(request);
|
|
84
|
+
const signed = assertHTTPSignedField(request.signed);
|
|
85
|
+
const valid = await VerifySignedData(account, signable, signed);
|
|
86
|
+
if (!valid) {
|
|
87
|
+
throw (new KeetaAnchorUserError('Invalid signature'));
|
|
88
|
+
}
|
|
89
|
+
return (account);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Verify a signed request from URL query parameters.
|
|
93
|
+
* Parses signature from URL, builds a request object, verifies the signature,
|
|
94
|
+
* and returns the authenticated account.
|
|
95
|
+
*
|
|
96
|
+
* @typeParam T - Request type to build from the account public key
|
|
97
|
+
*
|
|
98
|
+
* @param url - The URL containing signature query parameters
|
|
99
|
+
* @param getSigningData - Function to extract signable data from the request
|
|
100
|
+
* @param buildRequest - Function to build a request object from the account public key
|
|
101
|
+
*
|
|
102
|
+
* @returns The authenticated account
|
|
103
|
+
*
|
|
104
|
+
* @throws KeetaAnchorUserError if authentication is missing or invalid
|
|
105
|
+
*/
|
|
106
|
+
async function verifyURLAuth(url, getSigningData, buildRequest) {
|
|
107
|
+
let urlString;
|
|
108
|
+
if (typeof url === 'string') {
|
|
109
|
+
urlString = url;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
urlString = url.href;
|
|
113
|
+
}
|
|
114
|
+
const parsed = parseSignatureFromURL(urlString);
|
|
115
|
+
if (!parsed.account || !parsed.signedField) {
|
|
116
|
+
throw (new KeetaAnchorUserError('Authentication required'));
|
|
117
|
+
}
|
|
118
|
+
const request = buildRequest(parsed.account.publicKeyString.get());
|
|
119
|
+
const signable = getSigningData(request);
|
|
120
|
+
const valid = await VerifySignedData(parsed.account, signable, parsed.signedField);
|
|
121
|
+
if (!valid) {
|
|
122
|
+
throw (new KeetaAnchorUserError('Invalid signature'));
|
|
123
|
+
}
|
|
124
|
+
return (parsed.account);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Extract object path from wildcard route parameter.
|
|
128
|
+
* Prepends a leading slash to create a valid storage path.
|
|
129
|
+
*
|
|
130
|
+
* @param params - Route parameters containing the wildcard match
|
|
131
|
+
*
|
|
132
|
+
* @returns The object path with leading slash
|
|
133
|
+
*
|
|
134
|
+
* @throws InvalidPath if wildcard parameter is missing
|
|
135
|
+
*/
|
|
136
|
+
function extractObjectPath(params) {
|
|
137
|
+
const wildcardPath = params.get('**');
|
|
138
|
+
if (!wildcardPath) {
|
|
139
|
+
throw (new Errors.InvalidPath());
|
|
140
|
+
}
|
|
141
|
+
return ('/' + wildcardPath);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Authorize access to an object path via URL-signed request.
|
|
145
|
+
* Combines path validation, signature verification, and access control.
|
|
146
|
+
*
|
|
147
|
+
* @typeParam T - Request type to build from path and account
|
|
148
|
+
*
|
|
149
|
+
* @param pathPolicies - Array of path policies to check against
|
|
150
|
+
* @param params - Route parameters containing the wildcard path
|
|
151
|
+
* @param url - The URL containing signature query parameters
|
|
152
|
+
* @param operation - The operation being authorized
|
|
153
|
+
* @param getSigningData - Function to extract signable data from the request
|
|
154
|
+
* @param buildRequest - Function to build a request object from path and account
|
|
155
|
+
*
|
|
156
|
+
* @returns The authenticated account and validated object path
|
|
157
|
+
*
|
|
158
|
+
* @throws InvalidPath if path is invalid or doesn't match any policy
|
|
159
|
+
* @throws AccessDenied if user doesn't have access to the path
|
|
160
|
+
* @throws KeetaAnchorUserError if signature is invalid
|
|
161
|
+
*/
|
|
162
|
+
async function authorizeURLAccess(pathPolicies, params, url, operation, getSigningData, buildRequest) {
|
|
163
|
+
const objectPath = extractObjectPath(params);
|
|
164
|
+
parsePath(pathPolicies, objectPath);
|
|
165
|
+
const account = await verifyURLAuth(url, getSigningData, function (pubKey) {
|
|
166
|
+
return (buildRequest(objectPath, pubKey));
|
|
167
|
+
});
|
|
168
|
+
assertPathAccess(pathPolicies, account, objectPath, operation);
|
|
169
|
+
return ({ account, objectPath });
|
|
170
|
+
}
|
|
171
|
+
// Default quota configuration
|
|
172
|
+
const DEFAULT_QUOTAS = {
|
|
173
|
+
maxObjectSize: 10 * 1024 * 1024, // 10MB
|
|
174
|
+
maxObjectsPerUser: 1000,
|
|
175
|
+
maxStoragePerUser: 100 * 1024 * 1024, // 100MB
|
|
176
|
+
maxSearchLimit: 100,
|
|
177
|
+
maxSignedUrlTTL: 86400 // 24 hours
|
|
178
|
+
};
|
|
179
|
+
// Default tag validation configuration
|
|
180
|
+
const DEFAULT_TAG_VALIDATION = {
|
|
181
|
+
maxTags: 10,
|
|
182
|
+
maxTagLength: 50,
|
|
183
|
+
pattern: /^[a-zA-Z0-9_-]+$/
|
|
184
|
+
};
|
|
185
|
+
export class KeetaNetStorageAnchorHTTPServer extends KeetaAnchorHTTPServer.KeetaNetAnchorHTTPServer {
|
|
186
|
+
homepage;
|
|
187
|
+
backend;
|
|
188
|
+
anchorAccount;
|
|
189
|
+
quotas;
|
|
190
|
+
validators;
|
|
191
|
+
signedUrlDefaultTTL;
|
|
192
|
+
publicCorsOrigin;
|
|
193
|
+
pathPolicies;
|
|
194
|
+
tagValidation;
|
|
195
|
+
constructor(config) {
|
|
196
|
+
super(config);
|
|
197
|
+
this.homepage = config.homepage ?? '';
|
|
198
|
+
this.backend = config.backend;
|
|
199
|
+
this.anchorAccount = config.anchorAccount;
|
|
200
|
+
this.quotas = { ...DEFAULT_QUOTAS, ...config.quotas };
|
|
201
|
+
this.validators = config.validators ?? [];
|
|
202
|
+
this.signedUrlDefaultTTL = config.signedUrlDefaultTTL ?? DEFAULT_SIGNED_URL_TTL_SECONDS;
|
|
203
|
+
this.publicCorsOrigin = config.publicCorsOrigin ?? false;
|
|
204
|
+
this.pathPolicies = config.pathPolicies;
|
|
205
|
+
this.tagValidation = {
|
|
206
|
+
maxTags: config.tagValidation?.maxTags ?? DEFAULT_TAG_VALIDATION.maxTags,
|
|
207
|
+
maxTagLength: config.tagValidation?.maxTagLength ?? DEFAULT_TAG_VALIDATION.maxTagLength,
|
|
208
|
+
pattern: config.tagValidation?.pattern ?? DEFAULT_TAG_VALIDATION.pattern
|
|
209
|
+
};
|
|
210
|
+
// Validate anchorAccount has private key
|
|
211
|
+
if (!this.anchorAccount.hasPrivateKey) {
|
|
212
|
+
throw (new Error('anchorAccount must have a private key'));
|
|
213
|
+
}
|
|
214
|
+
// Validate at least one path policy is provided
|
|
215
|
+
if (this.pathPolicies.length === 0) {
|
|
216
|
+
throw (new Error('At least one path policy must be provided'));
|
|
217
|
+
}
|
|
218
|
+
// Validate quota configuration values are positive
|
|
219
|
+
if (this.quotas.maxObjectSize <= 0) {
|
|
220
|
+
throw (new Error('quotas.maxObjectSize must be positive'));
|
|
221
|
+
}
|
|
222
|
+
if (this.quotas.maxObjectsPerUser <= 0) {
|
|
223
|
+
throw (new Error('quotas.maxObjectsPerUser must be positive'));
|
|
224
|
+
}
|
|
225
|
+
if (this.quotas.maxStoragePerUser <= 0) {
|
|
226
|
+
throw (new Error('quotas.maxStoragePerUser must be positive'));
|
|
227
|
+
}
|
|
228
|
+
if (this.quotas.maxSearchLimit <= 0) {
|
|
229
|
+
throw (new Error('quotas.maxSearchLimit must be positive'));
|
|
230
|
+
}
|
|
231
|
+
if (this.quotas.maxSignedUrlTTL <= 0) {
|
|
232
|
+
throw (new Error('quotas.maxSignedUrlTTL must be positive'));
|
|
233
|
+
}
|
|
234
|
+
// Validate tag validation configuration
|
|
235
|
+
if (this.tagValidation.maxTags <= 0) {
|
|
236
|
+
throw (new Error('tagValidation.maxTags must be positive'));
|
|
237
|
+
}
|
|
238
|
+
if (this.tagValidation.maxTagLength <= 0) {
|
|
239
|
+
throw (new Error('tagValidation.maxTagLength must be positive'));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Note: We use this.* properties instead of config.*.
|
|
243
|
+
// The config parameter is required by the abstract method signature but unused here.
|
|
244
|
+
async initRoutes(_ignoreConfig) {
|
|
245
|
+
const routes = {};
|
|
246
|
+
const backend = this.backend;
|
|
247
|
+
const anchorAccount = this.anchorAccount;
|
|
248
|
+
const quotas = this.quotas;
|
|
249
|
+
const validators = this.validators;
|
|
250
|
+
const publicCorsOrigin = this.publicCorsOrigin;
|
|
251
|
+
const pathPolicies = this.pathPolicies;
|
|
252
|
+
const tagValidation = this.tagValidation;
|
|
253
|
+
const logger = this.logger;
|
|
254
|
+
/**
|
|
255
|
+
* Build a JSON response with assertion.
|
|
256
|
+
*/
|
|
257
|
+
function jsonResponse(response, assertionHandler) {
|
|
258
|
+
return ({ output: JSON.stringify(assertionHandler(response)) });
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Get an object or throw DocumentNotFound.
|
|
262
|
+
*/
|
|
263
|
+
async function requireObject(path) {
|
|
264
|
+
const result = await backend.get(path);
|
|
265
|
+
if (!result) {
|
|
266
|
+
throw (new Errors.DocumentNotFound());
|
|
267
|
+
}
|
|
268
|
+
return (result);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Enforce server-side search limit cap.
|
|
272
|
+
*/
|
|
273
|
+
function enforceSearchLimit(pagination) {
|
|
274
|
+
const requestedLimit = pagination?.limit ?? quotas.maxSearchLimit;
|
|
275
|
+
return ({ ...pagination, limit: Math.min(requestedLimit, quotas.maxSearchLimit) });
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Validate search results match expected constraints.
|
|
279
|
+
*/
|
|
280
|
+
function assertSearchResults(results, constraint) {
|
|
281
|
+
for (const obj of results.results) {
|
|
282
|
+
if (constraint.visibility && obj.visibility !== constraint.visibility) {
|
|
283
|
+
throw (new Errors.InvariantViolation(`Backend returned ${obj.visibility} object in ${constraint.visibility} search`));
|
|
284
|
+
}
|
|
285
|
+
if (constraint.owner && obj.owner !== constraint.owner) {
|
|
286
|
+
throw (new Errors.InvariantViolation(`Backend returned object owned by ${obj.owner} in search for ${constraint.owner}`));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* If a homepage is provided, setup the route for it
|
|
292
|
+
*/
|
|
293
|
+
const homepage = this.homepage;
|
|
294
|
+
if (homepage) {
|
|
295
|
+
routes['GET /'] = async function () {
|
|
296
|
+
let homepageData;
|
|
297
|
+
if (typeof homepage === 'string') {
|
|
298
|
+
homepageData = homepage;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
homepageData = await homepage();
|
|
302
|
+
}
|
|
303
|
+
return ({
|
|
304
|
+
output: homepageData,
|
|
305
|
+
contentType: 'text/html'
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// #region API Routes
|
|
310
|
+
// PUT /api/object/* - Create or update an object
|
|
311
|
+
routes['PUT /api/object/**'] = {
|
|
312
|
+
bodyType: 'raw',
|
|
313
|
+
maxBodySize: quotas.maxObjectSize,
|
|
314
|
+
handler: async function (params, postData, _headers, url) {
|
|
315
|
+
const objectPath = extractObjectPath(params);
|
|
316
|
+
// Get metadata from query params
|
|
317
|
+
const parsedUrl = new URL(url);
|
|
318
|
+
const visibilityParam = parsedUrl.searchParams.get('visibility');
|
|
319
|
+
const tagsParam = parsedUrl.searchParams.get('tags');
|
|
320
|
+
// Default to private when absent, assert valid value otherwise
|
|
321
|
+
let visibility = 'private';
|
|
322
|
+
if (visibilityParam !== null) {
|
|
323
|
+
visibility = assertVisibility(visibilityParam);
|
|
324
|
+
}
|
|
325
|
+
const rawTags = (tagsParam ?? '')
|
|
326
|
+
.split(',')
|
|
327
|
+
.map(function (t) {
|
|
328
|
+
return (t.trim());
|
|
329
|
+
})
|
|
330
|
+
.filter(function (t) {
|
|
331
|
+
return (t.length > 0);
|
|
332
|
+
});
|
|
333
|
+
// Verify signature
|
|
334
|
+
const account = await verifyURLAuth(url, getKeetaStorageAnchorPutRequestSigningData, function () {
|
|
335
|
+
return ({ path: objectPath, visibility, tags: rawTags });
|
|
336
|
+
});
|
|
337
|
+
// Validate tags
|
|
338
|
+
const { maxTags, maxTagLength, pattern: tagPattern } = tagValidation;
|
|
339
|
+
for (const tag of rawTags) {
|
|
340
|
+
if (tag.length > maxTagLength) {
|
|
341
|
+
throw (new Errors.InvalidTag(`Tag exceeds maximum length of ${maxTagLength}: "${tag}"`));
|
|
342
|
+
}
|
|
343
|
+
// Reset lastIndex in case the pattern has the global or sticky flag
|
|
344
|
+
tagPattern.lastIndex = 0;
|
|
345
|
+
if (!tagPattern.test(tag)) {
|
|
346
|
+
throw (new Errors.InvalidTag(`Tag contains invalid characters: "${tag}"`));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (rawTags.length > maxTags) {
|
|
350
|
+
throw (new Errors.InvalidTag(`Too many tags: ${rawTags.length} exceeds maximum of ${maxTags}`));
|
|
351
|
+
}
|
|
352
|
+
const tags = rawTags;
|
|
353
|
+
// Validate path format, metadata, and ownership
|
|
354
|
+
const { policy, parsed } = assertPathAccess(pathPolicies, account, objectPath, 'put');
|
|
355
|
+
const owner = account.publicKeyString.get();
|
|
356
|
+
if (policy.validateMetadata) {
|
|
357
|
+
policy.validateMetadata(parsed, { owner, tags, visibility });
|
|
358
|
+
}
|
|
359
|
+
// Resolve per-user quota limits, falling back to global config
|
|
360
|
+
const userLimits = backend.getQuotaLimits
|
|
361
|
+
? await backend.getQuotaLimits(owner)
|
|
362
|
+
: null;
|
|
363
|
+
const effectiveLimits = userLimits ?? quotas;
|
|
364
|
+
// Body is raw binary (EncryptedContainer)
|
|
365
|
+
const data = arrayBufferLikeToBuffer(postData);
|
|
366
|
+
const objectSize = data.byteLength;
|
|
367
|
+
if (objectSize > effectiveLimits.maxObjectSize) {
|
|
368
|
+
throw (new Errors.QuotaExceeded({
|
|
369
|
+
quotaType: 'maxObjectSize',
|
|
370
|
+
limit: effectiveLimits.maxObjectSize,
|
|
371
|
+
current: objectSize
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
const needsValidation = requiresValidation(objectPath, validators);
|
|
375
|
+
const needsAnchorDecryption = needsValidation || visibility === 'public';
|
|
376
|
+
if (needsAnchorDecryption) {
|
|
377
|
+
try {
|
|
378
|
+
const container = EncryptedContainer.fromEncryptedBuffer(data, [anchorAccount]);
|
|
379
|
+
const plaintext = await container.getPlaintext();
|
|
380
|
+
if (needsValidation) {
|
|
381
|
+
// Extract content and mimeType from encrypted payload
|
|
382
|
+
const { content, mimeType } = parseContainerPayload(plaintext);
|
|
383
|
+
const matchingValidators = findMatchingValidators(objectPath, validators);
|
|
384
|
+
for (const validator of matchingValidators) {
|
|
385
|
+
const result = await validator.validate(objectPath, content, mimeType);
|
|
386
|
+
if (!result.valid) {
|
|
387
|
+
throw (new Errors.ValidationFailed(result.error));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
if (Errors.ValidationFailed.isInstance(e)) {
|
|
394
|
+
throw (e);
|
|
395
|
+
}
|
|
396
|
+
if (EncryptedContainerError.isInstance(e)) {
|
|
397
|
+
if (e.code.startsWith('MALFORMED_')) {
|
|
398
|
+
throw (new Errors.ValidationFailed(`Invalid encrypted container: ${e.message}`));
|
|
399
|
+
}
|
|
400
|
+
if (e.code === 'NO_MATCHING_KEY' || e.code === 'DECRYPTION_FAILED') {
|
|
401
|
+
throw (new Errors.AnchorPrincipalRequired());
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
throw (e);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Reserve quota before upload
|
|
408
|
+
const reservation = await backend.reserveUpload(owner, objectPath, objectSize, {
|
|
409
|
+
quotaLimits: {
|
|
410
|
+
maxObjectsPerUser: effectiveLimits.maxObjectsPerUser,
|
|
411
|
+
maxStoragePerUser: effectiveLimits.maxStoragePerUser
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
let objectMetadata;
|
|
415
|
+
try {
|
|
416
|
+
objectMetadata = await backend.put(objectPath, data, {
|
|
417
|
+
owner,
|
|
418
|
+
tags,
|
|
419
|
+
visibility
|
|
420
|
+
});
|
|
421
|
+
await backend.commitUpload(reservation.id);
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
try {
|
|
425
|
+
await backend.releaseUpload(reservation.id);
|
|
426
|
+
}
|
|
427
|
+
catch (releaseError) {
|
|
428
|
+
/**
|
|
429
|
+
* This provides a hint for cleanup
|
|
430
|
+
*/
|
|
431
|
+
logger?.warn('Failed to release upload reservation', { reservationId: reservation.id, error: releaseError });
|
|
432
|
+
}
|
|
433
|
+
throw (e);
|
|
434
|
+
}
|
|
435
|
+
const response = {
|
|
436
|
+
ok: true,
|
|
437
|
+
object: objectMetadata
|
|
438
|
+
};
|
|
439
|
+
return (jsonResponse(response, assertKeetaStorageAnchorPutResponse));
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
// GET /api/object/* - Retrieve an object
|
|
443
|
+
routes['GET /api/object/**'] = async function (params, _postData, _headers, url) {
|
|
444
|
+
const { objectPath } = await authorizeURLAccess(pathPolicies, params, url, 'get', getKeetaStorageAnchorGetRequestSigningData, function (path, pubKey) {
|
|
445
|
+
return (assertKeetaStorageAnchorGetRequest({ path, account: pubKey }));
|
|
446
|
+
});
|
|
447
|
+
const result = await requireObject(objectPath);
|
|
448
|
+
return ({
|
|
449
|
+
output: result.data,
|
|
450
|
+
contentType: CONTENT_TYPE_OCTET_STREAM
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
// DELETE /api/object/* - Delete an object
|
|
454
|
+
routes['DELETE /api/object/**'] = async function (params, _postData, _headers, url) {
|
|
455
|
+
const { objectPath } = await authorizeURLAccess(pathPolicies, params, url, 'delete', getKeetaStorageAnchorDeleteRequestSigningData, function (path, pubKey) {
|
|
456
|
+
return ({ path, account: pubKey });
|
|
457
|
+
});
|
|
458
|
+
const deleted = await backend.delete(objectPath);
|
|
459
|
+
const response = {
|
|
460
|
+
ok: true,
|
|
461
|
+
deleted
|
|
462
|
+
};
|
|
463
|
+
return (jsonResponse(response, assertKeetaStorageAnchorDeleteResponse));
|
|
464
|
+
};
|
|
465
|
+
// GET /api/metadata/* - Get object metadata
|
|
466
|
+
routes['GET /api/metadata/**'] = async function (params, _postData, _headers, url) {
|
|
467
|
+
const { objectPath } = await authorizeURLAccess(pathPolicies, params, url, 'metadata', getKeetaStorageAnchorGetRequestSigningData, function (path, pubKey) {
|
|
468
|
+
return (assertKeetaStorageAnchorGetRequest({ path, account: pubKey }));
|
|
469
|
+
});
|
|
470
|
+
const result = await requireObject(objectPath);
|
|
471
|
+
return (jsonResponse({ ok: true, object: result.metadata }, assertKeetaStorageAnchorPutResponse));
|
|
472
|
+
};
|
|
473
|
+
// POST /api/search - Search for objects
|
|
474
|
+
routes['POST /api/search'] = async function (_params, postData) {
|
|
475
|
+
const request = assertKeetaStorageAnchorSearchRequest(postData);
|
|
476
|
+
const account = await verifyBodyAuth(request, getKeetaStorageAnchorSearchRequestSigningData);
|
|
477
|
+
const accountPubKey = account.publicKeyString.get();
|
|
478
|
+
// Check if searching for public objects outside namespace
|
|
479
|
+
const searchingPublic = request.criteria.visibility === 'public';
|
|
480
|
+
if (searchingPublic) {
|
|
481
|
+
// When searching for public objects, we allow searching outside the caller's namespace
|
|
482
|
+
// but only for objects with visibility: 'public'
|
|
483
|
+
const scopedCriteria = {
|
|
484
|
+
...request.criteria,
|
|
485
|
+
visibility: 'public'
|
|
486
|
+
};
|
|
487
|
+
const results = await backend.search(scopedCriteria, enforceSearchLimit(request.pagination));
|
|
488
|
+
assertSearchResults(results, { visibility: 'public' });
|
|
489
|
+
return (jsonResponse(buildSearchResponse(results), assertKeetaStorageAnchorSearchResponse));
|
|
490
|
+
}
|
|
491
|
+
// Scope search to authenticated account's namespace
|
|
492
|
+
const scopedCriteria = {
|
|
493
|
+
...request.criteria,
|
|
494
|
+
owner: accountPubKey
|
|
495
|
+
};
|
|
496
|
+
const results = await backend.search(scopedCriteria, enforceSearchLimit(request.pagination));
|
|
497
|
+
assertSearchResults(results, { owner: accountPubKey });
|
|
498
|
+
return (jsonResponse(buildSearchResponse(results), assertKeetaStorageAnchorSearchResponse));
|
|
499
|
+
};
|
|
500
|
+
// GET /api/quota - Get quota status
|
|
501
|
+
routes['GET /api/quota'] = async function (_params, _postData, _headers, url) {
|
|
502
|
+
const account = await verifyURLAuth(url, getKeetaStorageAnchorQuotaRequestSigningData, function () { return ({}); });
|
|
503
|
+
// Get current usage from backend and compute remaining using per-user or global limits
|
|
504
|
+
const owner = account.publicKeyString.get();
|
|
505
|
+
const userLimits = backend.getQuotaLimits
|
|
506
|
+
? await backend.getQuotaLimits(owner)
|
|
507
|
+
: null;
|
|
508
|
+
const effectiveLimits = userLimits ?? quotas;
|
|
509
|
+
const backendStatus = await backend.getQuotaStatus(owner);
|
|
510
|
+
// Compute remaining from config limits
|
|
511
|
+
let remainingObjects = Math.max(0, effectiveLimits.maxObjectsPerUser - backendStatus.objectCount);
|
|
512
|
+
let remainingSize = Math.max(0, effectiveLimits.maxStoragePerUser - backendStatus.totalSize);
|
|
513
|
+
// If backend reports its own remaining values, use the tighter constraint
|
|
514
|
+
if (backendStatus.remainingObjects !== undefined && backendStatus.remainingObjects > 0) {
|
|
515
|
+
remainingObjects = Math.min(backendStatus.remainingObjects, remainingObjects);
|
|
516
|
+
}
|
|
517
|
+
if (backendStatus.remainingSize !== undefined && backendStatus.remainingSize > 0) {
|
|
518
|
+
remainingSize = Math.min(backendStatus.remainingSize, remainingSize);
|
|
519
|
+
}
|
|
520
|
+
const response = {
|
|
521
|
+
ok: true,
|
|
522
|
+
quota: {
|
|
523
|
+
objectCount: backendStatus.objectCount,
|
|
524
|
+
totalSize: backendStatus.totalSize,
|
|
525
|
+
remainingObjects,
|
|
526
|
+
remainingSize
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
return (jsonResponse(response, assertKeetaStorageAnchorQuotaResponse));
|
|
530
|
+
};
|
|
531
|
+
// GET /api/public/** - Public object access via pre-signed URL
|
|
532
|
+
routes['GET /api/public/**'] = async function (params, _postData, _headers, url) {
|
|
533
|
+
const objectPath = extractObjectPath(params);
|
|
534
|
+
const { policy, parsed } = parsePath(pathPolicies, objectPath);
|
|
535
|
+
// Parse signature using standard signed field convention
|
|
536
|
+
const urlParsed = parseSignatureFromURL(url);
|
|
537
|
+
if (!urlParsed.signedField) {
|
|
538
|
+
throw (new Errors.SignatureInvalid('Missing required signature parameters'));
|
|
539
|
+
}
|
|
540
|
+
// Resolve signer: policy-specified or from URL account param (any-signer for public objects)
|
|
541
|
+
const signerAccount = policy.getAuthorizedSigner(parsed) ?? urlParsed.account ?? null;
|
|
542
|
+
if (!signerAccount) {
|
|
543
|
+
throw (new Errors.SignatureInvalid('Missing signer'));
|
|
544
|
+
}
|
|
545
|
+
const signerPubKey = signerAccount.publicKeyString.get();
|
|
546
|
+
// Parse and validate expires param
|
|
547
|
+
const parsedUrl = typeof url === 'string' ? new URL(url) : url;
|
|
548
|
+
const expiresParam = parsedUrl.searchParams.get('expires');
|
|
549
|
+
if (!expiresParam) {
|
|
550
|
+
throw (new Errors.SignatureInvalid('Missing expires parameter'));
|
|
551
|
+
}
|
|
552
|
+
const expiresAt = parseInt(expiresParam, 10);
|
|
553
|
+
if (!Number.isFinite(expiresAt)) {
|
|
554
|
+
throw (new Errors.SignatureInvalid('Invalid expires parameter'));
|
|
555
|
+
}
|
|
556
|
+
if (Date.now() > expiresAt * 1000) {
|
|
557
|
+
throw (new Errors.SignatureExpired());
|
|
558
|
+
}
|
|
559
|
+
// Enforce maximum TTL
|
|
560
|
+
const maxExpiresAt = Math.floor(Date.now() / 1000) + quotas.maxSignedUrlTTL;
|
|
561
|
+
if (expiresAt > maxExpiresAt) {
|
|
562
|
+
throw (new Errors.SignatureExpired('Signed URL TTL exceeds maximum allowed'));
|
|
563
|
+
}
|
|
564
|
+
// Pre-validate signature is valid base64 with reasonable length
|
|
565
|
+
const signatureBuffer = Buffer.from(urlParsed.signedField.signature, 'base64');
|
|
566
|
+
if (signatureBuffer.length < 64 || signatureBuffer.length > 256) {
|
|
567
|
+
throw (new Errors.SignatureInvalid('Invalid signature format'));
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
// Allow 5 minutes of clock skew for signature verification
|
|
571
|
+
const valid = await VerifySignedData(signerAccount, [objectPath, expiresAt, signerPubKey], urlParsed.signedField, {
|
|
572
|
+
maxSkewMs: 5 * 60 * 1000
|
|
573
|
+
});
|
|
574
|
+
if (!valid) {
|
|
575
|
+
throw (new Errors.SignatureInvalid());
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
if (Errors.SignatureInvalid.isInstance(e)) {
|
|
580
|
+
throw (e);
|
|
581
|
+
}
|
|
582
|
+
throw (new Errors.SignatureInvalid('Signature verification failed'));
|
|
583
|
+
}
|
|
584
|
+
const result = await requireObject(objectPath);
|
|
585
|
+
if (result.metadata.visibility !== 'public') {
|
|
586
|
+
throw (new Errors.AccessDenied('Object is not public'));
|
|
587
|
+
}
|
|
588
|
+
// Decrypt using anchor account and extract mimeType from encrypted payload
|
|
589
|
+
const data = arrayBufferLikeToBuffer(result.data);
|
|
590
|
+
const container = EncryptedContainer.fromEncryptedBuffer(data, [anchorAccount]);
|
|
591
|
+
const plaintext = await container.getPlaintext();
|
|
592
|
+
const { content, mimeType } = parseContainerPayload(plaintext);
|
|
593
|
+
const headers = {};
|
|
594
|
+
if (publicCorsOrigin) {
|
|
595
|
+
headers['Access-Control-Allow-Origin'] = publicCorsOrigin;
|
|
596
|
+
}
|
|
597
|
+
return ({
|
|
598
|
+
output: content,
|
|
599
|
+
contentType: mimeType,
|
|
600
|
+
headers
|
|
601
|
+
});
|
|
602
|
+
};
|
|
603
|
+
// #endregion
|
|
604
|
+
return (routes);
|
|
605
|
+
}
|
|
606
|
+
async serviceMetadata() {
|
|
607
|
+
const authRequired = { options: { authentication: { type: 'required', method: 'keeta-account' } } };
|
|
608
|
+
const operations = {
|
|
609
|
+
put: { url: (new URL('/api/object', this.url)).toString(), ...authRequired },
|
|
610
|
+
get: { url: (new URL('/api/object', this.url)).toString(), ...authRequired },
|
|
611
|
+
delete: { url: (new URL('/api/object', this.url)).toString(), ...authRequired },
|
|
612
|
+
metadata: { url: (new URL('/api/metadata', this.url)).toString(), ...authRequired },
|
|
613
|
+
search: { url: (new URL('/api/search', this.url)).toString(), ...authRequired },
|
|
614
|
+
public: (new URL('/api/public', this.url)).toString(),
|
|
615
|
+
quota: { url: (new URL('/api/quota', this.url)).toString(), ...authRequired }
|
|
616
|
+
};
|
|
617
|
+
return ({
|
|
618
|
+
operations,
|
|
619
|
+
anchorAccount: this.anchorAccount.publicKeyString.get(),
|
|
620
|
+
quotas: this.quotas,
|
|
621
|
+
signedUrlDefaultTTL: this.signedUrlDefaultTTL,
|
|
622
|
+
searchableFields: ['owner', 'tags', 'visibility', 'pathPrefix']
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
//# sourceMappingURL=server.js.map
|