@tinycloud/sdk-services 1.7.0 → 2.0.2-beta.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/dist/{types.d.ts → BaseService-D9BFm_rV.d.cts} +179 -27
- package/dist/BaseService-D9BFm_rV.d.ts +440 -0
- package/dist/index.cjs +3221 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1843 -0
- package/dist/index.d.ts +1826 -41
- package/dist/index.js +3136 -58
- package/dist/index.js.map +1 -1
- package/dist/kv/index.cjs +909 -0
- package/dist/kv/index.cjs.map +1 -0
- package/dist/kv/index.d.cts +748 -0
- package/dist/kv/index.d.ts +745 -7
- package/dist/kv/index.js +877 -9
- package/dist/kv/index.js.map +1 -1
- package/dist/sql/index.cjs +596 -0
- package/dist/sql/index.cjs.map +1 -0
- package/dist/sql/index.d.cts +228 -0
- package/dist/sql/index.d.ts +225 -7
- package/dist/sql/index.js +566 -8
- package/dist/sql/index.js.map +1 -1
- package/package.json +7 -6
- package/dist/base/BaseService.d.ts +0 -151
- package/dist/base/BaseService.d.ts.map +0 -1
- package/dist/base/BaseService.js +0 -221
- package/dist/base/BaseService.js.map +0 -1
- package/dist/base/index.d.ts +0 -6
- package/dist/base/index.d.ts.map +0 -1
- package/dist/base/index.js +0 -6
- package/dist/base/index.js.map +0 -1
- package/dist/base/types.d.ts +0 -36
- package/dist/base/types.d.ts.map +0 -1
- package/dist/base/types.js +0 -7
- package/dist/base/types.js.map +0 -1
- package/dist/context.d.ts +0 -142
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -218
- package/dist/context.js.map +0 -1
- package/dist/duckdb/DuckDbDatabaseHandle.d.ts +0 -23
- package/dist/duckdb/DuckDbDatabaseHandle.d.ts.map +0 -1
- package/dist/duckdb/DuckDbDatabaseHandle.js +0 -36
- package/dist/duckdb/DuckDbDatabaseHandle.js.map +0 -1
- package/dist/duckdb/DuckDbService.d.ts +0 -50
- package/dist/duckdb/DuckDbService.d.ts.map +0 -1
- package/dist/duckdb/DuckDbService.js +0 -285
- package/dist/duckdb/DuckDbService.js.map +0 -1
- package/dist/duckdb/IDuckDbService.d.ts +0 -84
- package/dist/duckdb/IDuckDbService.d.ts.map +0 -1
- package/dist/duckdb/IDuckDbService.js +0 -7
- package/dist/duckdb/IDuckDbService.js.map +0 -1
- package/dist/duckdb/index.d.ts +0 -10
- package/dist/duckdb/index.d.ts.map +0 -1
- package/dist/duckdb/index.js +0 -9
- package/dist/duckdb/index.js.map +0 -1
- package/dist/duckdb/types.d.ts +0 -148
- package/dist/duckdb/types.d.ts.map +0 -1
- package/dist/duckdb/types.js +0 -19
- package/dist/duckdb/types.js.map +0 -1
- package/dist/errors.d.ts +0 -62
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -149
- package/dist/errors.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/kv/IKVService.d.ts +0 -148
- package/dist/kv/IKVService.d.ts.map +0 -1
- package/dist/kv/IKVService.js +0 -8
- package/dist/kv/IKVService.js.map +0 -1
- package/dist/kv/KVService.d.ts +0 -155
- package/dist/kv/KVService.d.ts.map +0 -1
- package/dist/kv/KVService.js +0 -419
- package/dist/kv/KVService.js.map +0 -1
- package/dist/kv/PrefixedKVService.d.ts +0 -246
- package/dist/kv/PrefixedKVService.d.ts.map +0 -1
- package/dist/kv/PrefixedKVService.js +0 -145
- package/dist/kv/PrefixedKVService.js.map +0 -1
- package/dist/kv/index.d.ts.map +0 -1
- package/dist/kv/types.d.ts +0 -204
- package/dist/kv/types.d.ts.map +0 -1
- package/dist/kv/types.js +0 -16
- package/dist/kv/types.js.map +0 -1
- package/dist/quota/TinyCloudQuota.d.ts +0 -27
- package/dist/quota/TinyCloudQuota.d.ts.map +0 -1
- package/dist/quota/TinyCloudQuota.js +0 -31
- package/dist/quota/TinyCloudQuota.js.map +0 -1
- package/dist/quota/index.d.ts +0 -3
- package/dist/quota/index.d.ts.map +0 -1
- package/dist/quota/index.js +0 -2
- package/dist/quota/index.js.map +0 -1
- package/dist/sql/DatabaseHandle.d.ts +0 -20
- package/dist/sql/DatabaseHandle.d.ts.map +0 -1
- package/dist/sql/DatabaseHandle.js +0 -27
- package/dist/sql/DatabaseHandle.js.map +0 -1
- package/dist/sql/ISQLService.d.ts +0 -67
- package/dist/sql/ISQLService.d.ts.map +0 -1
- package/dist/sql/ISQLService.js +0 -7
- package/dist/sql/ISQLService.js.map +0 -1
- package/dist/sql/SQLService.d.ts +0 -44
- package/dist/sql/SQLService.d.ts.map +0 -1
- package/dist/sql/SQLService.js +0 -216
- package/dist/sql/SQLService.js.map +0 -1
- package/dist/sql/index.d.ts.map +0 -1
- package/dist/sql/types.d.ts +0 -102
- package/dist/sql/types.d.ts.map +0 -1
- package/dist/sql/types.js +0 -21
- package/dist/sql/types.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -94
- package/dist/types.js.map +0 -1
- package/dist/types.schema.d.ts +0 -712
- package/dist/types.schema.d.ts.map +0 -1
- package/dist/types.schema.js +0 -342
- package/dist/types.schema.js.map +0 -1
- package/dist/types.schema.test.d.ts +0 -5
- package/dist/types.schema.test.d.ts.map +0 -1
- package/dist/types.schema.test.js +0 -677
- package/dist/types.schema.test.js.map +0 -1
- package/dist/vault/DataVaultService.d.ts +0 -258
- package/dist/vault/DataVaultService.d.ts.map +0 -1
- package/dist/vault/DataVaultService.js +0 -977
- package/dist/vault/DataVaultService.js.map +0 -1
- package/dist/vault/IDataVaultService.d.ts +0 -150
- package/dist/vault/IDataVaultService.d.ts.map +0 -1
- package/dist/vault/IDataVaultService.js +0 -8
- package/dist/vault/IDataVaultService.js.map +0 -1
- package/dist/vault/createVaultCrypto.d.ts +0 -16
- package/dist/vault/createVaultCrypto.d.ts.map +0 -1
- package/dist/vault/createVaultCrypto.js +0 -12
- package/dist/vault/createVaultCrypto.js.map +0 -1
- package/dist/vault/index.d.ts +0 -10
- package/dist/vault/index.d.ts.map +0 -1
- package/dist/vault/index.js +0 -11
- package/dist/vault/index.js.map +0 -1
- package/dist/vault/types.d.ts +0 -133
- package/dist/vault/types.d.ts.map +0 -1
- package/dist/vault/types.js +0 -23
- package/dist/vault/types.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,61 +1,3139 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var ErrorCodes = {
|
|
3
|
+
// Common errors
|
|
4
|
+
NOT_FOUND: "NOT_FOUND",
|
|
5
|
+
AUTH_EXPIRED: "AUTH_EXPIRED",
|
|
6
|
+
AUTH_REQUIRED: "AUTH_REQUIRED",
|
|
7
|
+
AUTH_UNAUTHORIZED: "AUTH_UNAUTHORIZED",
|
|
8
|
+
NETWORK_ERROR: "NETWORK_ERROR",
|
|
9
|
+
TIMEOUT: "TIMEOUT",
|
|
10
|
+
ABORTED: "ABORTED",
|
|
11
|
+
INVALID_INPUT: "INVALID_INPUT",
|
|
12
|
+
PERMISSION_DENIED: "PERMISSION_DENIED",
|
|
13
|
+
// KV-specific errors
|
|
14
|
+
KV_NOT_FOUND: "KV_NOT_FOUND",
|
|
15
|
+
KV_WRITE_FAILED: "KV_WRITE_FAILED",
|
|
16
|
+
// SQL-specific errors
|
|
17
|
+
SQL_ERROR: "SQL_ERROR",
|
|
18
|
+
SQL_PERMISSION_DENIED: "SQL_PERMISSION_DENIED",
|
|
19
|
+
SQL_DATABASE_NOT_FOUND: "SQL_DATABASE_NOT_FOUND",
|
|
20
|
+
SQL_RESPONSE_TOO_LARGE: "SQL_RESPONSE_TOO_LARGE",
|
|
21
|
+
SQL_QUOTA_EXCEEDED: "SQL_QUOTA_EXCEEDED",
|
|
22
|
+
SQL_INVALID_STATEMENT: "SQL_INVALID_STATEMENT",
|
|
23
|
+
SQL_SCHEMA_ERROR: "SQL_SCHEMA_ERROR",
|
|
24
|
+
SQL_READONLY_VIOLATION: "SQL_READONLY_VIOLATION",
|
|
25
|
+
// Storage quota errors
|
|
26
|
+
STORAGE_QUOTA_EXCEEDED: "STORAGE_QUOTA_EXCEEDED",
|
|
27
|
+
STORAGE_LIMIT_REACHED: "STORAGE_LIMIT_REACHED",
|
|
28
|
+
// DuckDB-specific errors
|
|
29
|
+
DUCKDB_ERROR: "DUCKDB_ERROR",
|
|
30
|
+
DUCKDB_PERMISSION_DENIED: "DUCKDB_PERMISSION_DENIED",
|
|
31
|
+
DUCKDB_DATABASE_NOT_FOUND: "DUCKDB_DATABASE_NOT_FOUND",
|
|
32
|
+
DUCKDB_RESPONSE_TOO_LARGE: "DUCKDB_RESPONSE_TOO_LARGE",
|
|
33
|
+
DUCKDB_QUOTA_EXCEEDED: "DUCKDB_QUOTA_EXCEEDED",
|
|
34
|
+
DUCKDB_INVALID_STATEMENT: "DUCKDB_INVALID_STATEMENT",
|
|
35
|
+
DUCKDB_SCHEMA_ERROR: "DUCKDB_SCHEMA_ERROR",
|
|
36
|
+
DUCKDB_READONLY_VIOLATION: "DUCKDB_READONLY_VIOLATION"
|
|
37
|
+
};
|
|
38
|
+
var defaultRetryPolicy = {
|
|
39
|
+
maxAttempts: 3,
|
|
40
|
+
backoff: "exponential",
|
|
41
|
+
baseDelayMs: 1e3,
|
|
42
|
+
maxDelayMs: 1e4,
|
|
43
|
+
retryableErrors: [ErrorCodes.NETWORK_ERROR, ErrorCodes.TIMEOUT]
|
|
44
|
+
};
|
|
45
|
+
var TelemetryEvents = {
|
|
46
|
+
SERVICE_REQUEST: "service.request",
|
|
47
|
+
SERVICE_RESPONSE: "service.response",
|
|
48
|
+
SERVICE_ERROR: "service.error",
|
|
49
|
+
SERVICE_RETRY: "service.retry",
|
|
50
|
+
SESSION_CHANGED: "session.changed",
|
|
51
|
+
SESSION_EXPIRED: "session.expired"
|
|
52
|
+
};
|
|
53
|
+
function ok(data) {
|
|
54
|
+
return { ok: true, data };
|
|
55
|
+
}
|
|
56
|
+
function err(error) {
|
|
57
|
+
return { ok: false, error };
|
|
58
|
+
}
|
|
59
|
+
function serviceError(code, message, service, options) {
|
|
60
|
+
return {
|
|
61
|
+
code,
|
|
62
|
+
message,
|
|
63
|
+
service,
|
|
64
|
+
cause: options?.cause,
|
|
65
|
+
meta: options?.meta
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/types.schema.ts
|
|
70
|
+
import { z } from "zod";
|
|
71
|
+
var ServiceErrorSchema = z.object({
|
|
72
|
+
/** Error code for programmatic handling (e.g., 'KV_NOT_FOUND', 'AUTH_EXPIRED') */
|
|
73
|
+
code: z.string(),
|
|
74
|
+
/** Human-readable error message */
|
|
75
|
+
message: z.string(),
|
|
76
|
+
/** Service that produced the error (e.g., 'kv', 'sql') */
|
|
77
|
+
service: z.string(),
|
|
78
|
+
/** Original error if this wraps another error - not validated since Error is a class */
|
|
79
|
+
cause: z.unknown().optional(),
|
|
80
|
+
/** Additional metadata about the error - passthrough allows any object properties */
|
|
81
|
+
meta: z.object({}).passthrough().optional()
|
|
82
|
+
});
|
|
83
|
+
function createResultSchema(dataSchema, errorSchema = ServiceErrorSchema) {
|
|
84
|
+
return z.discriminatedUnion("ok", [
|
|
85
|
+
z.object({
|
|
86
|
+
ok: z.literal(true),
|
|
87
|
+
data: dataSchema
|
|
88
|
+
}),
|
|
89
|
+
z.object({
|
|
90
|
+
ok: z.literal(false),
|
|
91
|
+
error: errorSchema
|
|
92
|
+
})
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
var GenericResultSchema = createResultSchema(z.unknown(), ServiceErrorSchema);
|
|
96
|
+
var KVResponseHeadersSchema = z.object({
|
|
97
|
+
/** ETag for conditional requests */
|
|
98
|
+
etag: z.string().optional(),
|
|
99
|
+
/** Content type of the stored value */
|
|
100
|
+
contentType: z.string().optional(),
|
|
101
|
+
/** Last modification timestamp */
|
|
102
|
+
lastModified: z.string().optional(),
|
|
103
|
+
/** Content length in bytes */
|
|
104
|
+
contentLength: z.number().optional()
|
|
105
|
+
});
|
|
106
|
+
function createKVResponseSchema(dataSchema) {
|
|
107
|
+
return z.object({
|
|
108
|
+
/** The data payload */
|
|
109
|
+
data: dataSchema,
|
|
110
|
+
/** Response headers with metadata */
|
|
111
|
+
headers: KVResponseHeadersSchema
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
var GenericKVResponseSchema = createKVResponseSchema(z.unknown());
|
|
115
|
+
var KVListResponseSchema = z.object({
|
|
116
|
+
/** Array of keys matching the list criteria */
|
|
117
|
+
keys: z.array(z.string())
|
|
118
|
+
});
|
|
119
|
+
var KVListResultSchema = createResultSchema(KVListResponseSchema);
|
|
120
|
+
var ServiceRequestEventSchema = z.object({
|
|
121
|
+
service: z.string(),
|
|
122
|
+
action: z.string(),
|
|
123
|
+
key: z.string().optional(),
|
|
124
|
+
timestamp: z.number()
|
|
125
|
+
});
|
|
126
|
+
var ServiceResponseEventSchema = z.object({
|
|
127
|
+
service: z.string(),
|
|
128
|
+
action: z.string(),
|
|
129
|
+
ok: z.boolean(),
|
|
130
|
+
duration: z.number(),
|
|
131
|
+
status: z.number().optional()
|
|
132
|
+
});
|
|
133
|
+
var ServiceErrorEventSchema = z.object({
|
|
134
|
+
service: z.string(),
|
|
135
|
+
error: ServiceErrorSchema
|
|
136
|
+
});
|
|
137
|
+
var ServiceRetryEventSchema = z.object({
|
|
138
|
+
service: z.string(),
|
|
139
|
+
attempt: z.number().int().positive(),
|
|
140
|
+
maxAttempts: z.number().int().positive(),
|
|
141
|
+
error: ServiceErrorSchema
|
|
142
|
+
});
|
|
143
|
+
var RetryPolicySchema = z.object({
|
|
144
|
+
/** Maximum number of attempts (including initial) */
|
|
145
|
+
maxAttempts: z.number().int().positive(),
|
|
146
|
+
/** Backoff strategy between retries */
|
|
147
|
+
backoff: z.enum(["none", "linear", "exponential"]),
|
|
148
|
+
/** Base delay in milliseconds for backoff calculation */
|
|
149
|
+
baseDelayMs: z.number().nonnegative(),
|
|
150
|
+
/** Maximum delay in milliseconds between retries */
|
|
151
|
+
maxDelayMs: z.number().nonnegative(),
|
|
152
|
+
/** Error codes that should trigger a retry */
|
|
153
|
+
retryableErrors: z.array(z.string())
|
|
154
|
+
});
|
|
155
|
+
var ServiceSessionSchema = z.object({
|
|
156
|
+
/** The delegation header containing the UCAN */
|
|
157
|
+
delegationHeader: z.object({
|
|
158
|
+
Authorization: z.string()
|
|
159
|
+
}),
|
|
160
|
+
/** The delegation CID */
|
|
161
|
+
delegationCid: z.string(),
|
|
162
|
+
/** The space ID for this session */
|
|
163
|
+
spaceId: z.string(),
|
|
164
|
+
/** The verification method DID */
|
|
165
|
+
verificationMethod: z.string(),
|
|
166
|
+
/** The session key JWK (required for invoke) */
|
|
167
|
+
jwk: z.object({}).passthrough()
|
|
168
|
+
});
|
|
169
|
+
function validateServiceError(data) {
|
|
170
|
+
const result = ServiceErrorSchema.safeParse(data);
|
|
171
|
+
if (!result.success) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
error: {
|
|
175
|
+
code: "VALIDATION_ERROR",
|
|
176
|
+
message: result.error.message,
|
|
177
|
+
service: "validation",
|
|
178
|
+
meta: { issues: result.error.issues }
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, data: result.data };
|
|
183
|
+
}
|
|
184
|
+
function validateKVListResponse(data) {
|
|
185
|
+
const result = KVListResponseSchema.safeParse(data);
|
|
186
|
+
if (!result.success) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: {
|
|
190
|
+
code: "VALIDATION_ERROR",
|
|
191
|
+
message: result.error.message,
|
|
192
|
+
service: "kv",
|
|
193
|
+
meta: { issues: result.error.issues }
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, data: result.data };
|
|
198
|
+
}
|
|
199
|
+
function validateKVResponseHeaders(data) {
|
|
200
|
+
const result = KVResponseHeadersSchema.safeParse(data);
|
|
201
|
+
if (!result.success) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
error: {
|
|
205
|
+
code: "VALIDATION_ERROR",
|
|
206
|
+
message: result.error.message,
|
|
207
|
+
service: "kv",
|
|
208
|
+
meta: { issues: result.error.issues }
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return { ok: true, data: result.data };
|
|
213
|
+
}
|
|
214
|
+
function validateServiceSession(data) {
|
|
215
|
+
const result = ServiceSessionSchema.safeParse(data);
|
|
216
|
+
if (!result.success) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: {
|
|
220
|
+
code: "VALIDATION_ERROR",
|
|
221
|
+
message: result.error.message,
|
|
222
|
+
service: "session",
|
|
223
|
+
meta: { issues: result.error.issues }
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { ok: true, data: result.data };
|
|
228
|
+
}
|
|
229
|
+
function validateRetryPolicy(data) {
|
|
230
|
+
const result = RetryPolicySchema.safeParse(data);
|
|
231
|
+
if (!result.success) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: {
|
|
235
|
+
code: "VALIDATION_ERROR",
|
|
236
|
+
message: result.error.message,
|
|
237
|
+
service: "config",
|
|
238
|
+
meta: { issues: result.error.issues }
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { ok: true, data: result.data };
|
|
243
|
+
}
|
|
244
|
+
function validateServiceRequestEvent(data) {
|
|
245
|
+
const result = ServiceRequestEventSchema.safeParse(data);
|
|
246
|
+
if (!result.success) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: {
|
|
250
|
+
code: "VALIDATION_ERROR",
|
|
251
|
+
message: result.error.message,
|
|
252
|
+
service: "telemetry",
|
|
253
|
+
meta: { issues: result.error.issues }
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, data: result.data };
|
|
258
|
+
}
|
|
259
|
+
function validateServiceResponseEvent(data) {
|
|
260
|
+
const result = ServiceResponseEventSchema.safeParse(data);
|
|
261
|
+
if (!result.success) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
error: {
|
|
265
|
+
code: "VALIDATION_ERROR",
|
|
266
|
+
message: result.error.message,
|
|
267
|
+
service: "telemetry",
|
|
268
|
+
meta: { issues: result.error.issues }
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, data: result.data };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/context.ts
|
|
276
|
+
var ServiceContext = class {
|
|
277
|
+
constructor(config) {
|
|
278
|
+
this._session = null;
|
|
279
|
+
this._services = /* @__PURE__ */ new Map();
|
|
280
|
+
this._eventHandlers = /* @__PURE__ */ new Map();
|
|
281
|
+
this._abortController = new AbortController();
|
|
282
|
+
this._invoke = config.invoke;
|
|
283
|
+
this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
284
|
+
this._hosts = config.hosts;
|
|
285
|
+
this._session = config.session ?? null;
|
|
286
|
+
this._retryPolicy = {
|
|
287
|
+
...defaultRetryPolicy,
|
|
288
|
+
...config.retryPolicy
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// ============================================================
|
|
292
|
+
// Session Management
|
|
293
|
+
// ============================================================
|
|
294
|
+
/**
|
|
295
|
+
* Get the current session.
|
|
296
|
+
*/
|
|
297
|
+
get session() {
|
|
298
|
+
return this._session;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Check if the context has an authenticated session.
|
|
302
|
+
*/
|
|
303
|
+
get isAuthenticated() {
|
|
304
|
+
return this._session !== null;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Update the session and notify all registered services.
|
|
308
|
+
*
|
|
309
|
+
* @param session - New session or null to clear
|
|
310
|
+
*/
|
|
311
|
+
setSession(session) {
|
|
312
|
+
this._session = session;
|
|
313
|
+
this.emit("session.changed", { authenticated: session !== null });
|
|
314
|
+
for (const service of this._services.values()) {
|
|
315
|
+
service.onSessionChange(session);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// ============================================================
|
|
319
|
+
// Platform Dependencies
|
|
320
|
+
// ============================================================
|
|
321
|
+
/**
|
|
322
|
+
* Get the invoke function for WASM operations.
|
|
323
|
+
*/
|
|
324
|
+
get invoke() {
|
|
325
|
+
return this._invoke;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Get the fetch function for HTTP requests.
|
|
329
|
+
*/
|
|
330
|
+
get fetch() {
|
|
331
|
+
return this._fetch;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Get the list of TinyCloud host URLs.
|
|
335
|
+
*/
|
|
336
|
+
get hosts() {
|
|
337
|
+
return this._hosts;
|
|
338
|
+
}
|
|
339
|
+
// ============================================================
|
|
340
|
+
// Service Registry
|
|
341
|
+
// ============================================================
|
|
342
|
+
/**
|
|
343
|
+
* Register a service with the context.
|
|
344
|
+
*
|
|
345
|
+
* @param name - Service name (e.g., 'kv')
|
|
346
|
+
* @param service - Service instance
|
|
347
|
+
*/
|
|
348
|
+
registerService(name, service) {
|
|
349
|
+
this._services.set(name, service);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Unregister a service from the context.
|
|
353
|
+
*
|
|
354
|
+
* @param name - Service name to remove
|
|
355
|
+
*/
|
|
356
|
+
unregisterService(name) {
|
|
357
|
+
this._services.delete(name);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Get a registered service by name.
|
|
361
|
+
*
|
|
362
|
+
* @param name - Service name
|
|
363
|
+
* @returns The service instance or undefined if not registered
|
|
364
|
+
*/
|
|
365
|
+
getService(name) {
|
|
366
|
+
return this._services.get(name);
|
|
367
|
+
}
|
|
368
|
+
// ============================================================
|
|
369
|
+
// Event System (Telemetry)
|
|
370
|
+
// ============================================================
|
|
371
|
+
/**
|
|
372
|
+
* Emit a telemetry event.
|
|
373
|
+
*
|
|
374
|
+
* @param event - Event name
|
|
375
|
+
* @param data - Event data
|
|
376
|
+
*/
|
|
377
|
+
emit(event, data) {
|
|
378
|
+
const handlers = this._eventHandlers.get(event);
|
|
379
|
+
if (handlers) {
|
|
380
|
+
for (const handler of handlers) {
|
|
381
|
+
try {
|
|
382
|
+
handler(data);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
console.error(`Error in event handler for "${event}":`, error);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Subscribe to telemetry events.
|
|
391
|
+
*
|
|
392
|
+
* @param event - Event name to subscribe to
|
|
393
|
+
* @param handler - Handler function
|
|
394
|
+
* @returns Unsubscribe function
|
|
395
|
+
*/
|
|
396
|
+
on(event, handler) {
|
|
397
|
+
if (!this._eventHandlers.has(event)) {
|
|
398
|
+
this._eventHandlers.set(event, /* @__PURE__ */ new Set());
|
|
399
|
+
}
|
|
400
|
+
this._eventHandlers.get(event).add(handler);
|
|
401
|
+
return () => {
|
|
402
|
+
const handlers = this._eventHandlers.get(event);
|
|
403
|
+
if (handlers) {
|
|
404
|
+
handlers.delete(handler);
|
|
405
|
+
if (handlers.size === 0) {
|
|
406
|
+
this._eventHandlers.delete(event);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Remove all event handlers for an event.
|
|
413
|
+
*
|
|
414
|
+
* @param event - Event name (if omitted, clears all events)
|
|
415
|
+
*/
|
|
416
|
+
clearEventHandlers(event) {
|
|
417
|
+
if (event) {
|
|
418
|
+
this._eventHandlers.delete(event);
|
|
419
|
+
} else {
|
|
420
|
+
this._eventHandlers.clear();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// ============================================================
|
|
424
|
+
// Lifecycle
|
|
425
|
+
// ============================================================
|
|
426
|
+
/**
|
|
427
|
+
* Get the abort signal for cancelling operations.
|
|
428
|
+
*/
|
|
429
|
+
get abortSignal() {
|
|
430
|
+
return this._abortController.signal;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Abort all pending operations and notify services.
|
|
434
|
+
* Creates a new AbortController for future operations.
|
|
435
|
+
*/
|
|
436
|
+
abort() {
|
|
437
|
+
this._abortController.abort();
|
|
438
|
+
this._abortController = new AbortController();
|
|
439
|
+
for (const service of this._services.values()) {
|
|
440
|
+
service.onSignOut();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Sign out - abort operations and clear session.
|
|
445
|
+
*/
|
|
446
|
+
signOut() {
|
|
447
|
+
this.abort();
|
|
448
|
+
this.setSession(null);
|
|
449
|
+
this.emit("session.expired", {});
|
|
450
|
+
}
|
|
451
|
+
// ============================================================
|
|
452
|
+
// Retry Policy
|
|
453
|
+
// ============================================================
|
|
454
|
+
/**
|
|
455
|
+
* Get the retry policy configuration.
|
|
456
|
+
*/
|
|
457
|
+
get retryPolicy() {
|
|
458
|
+
return this._retryPolicy;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// src/errors.ts
|
|
463
|
+
function authRequiredError(service) {
|
|
464
|
+
return {
|
|
465
|
+
code: ErrorCodes.AUTH_REQUIRED,
|
|
466
|
+
message: "Authentication required. Please sign in first.",
|
|
467
|
+
service
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function authExpiredError(service) {
|
|
471
|
+
return {
|
|
472
|
+
code: ErrorCodes.AUTH_EXPIRED,
|
|
473
|
+
message: "Session has expired. Please sign in again.",
|
|
474
|
+
service
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function networkError(service, message, cause) {
|
|
478
|
+
return {
|
|
479
|
+
code: ErrorCodes.NETWORK_ERROR,
|
|
480
|
+
message,
|
|
481
|
+
service,
|
|
482
|
+
cause
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function timeoutError(service) {
|
|
486
|
+
return {
|
|
487
|
+
code: ErrorCodes.TIMEOUT,
|
|
488
|
+
message: "Request timed out.",
|
|
489
|
+
service
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function abortedError(service) {
|
|
493
|
+
return {
|
|
494
|
+
code: ErrorCodes.ABORTED,
|
|
495
|
+
message: "Request was aborted.",
|
|
496
|
+
service
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function notFoundError(service, resource) {
|
|
500
|
+
return {
|
|
501
|
+
code: ErrorCodes.NOT_FOUND,
|
|
502
|
+
message: `Resource not found: ${resource}`,
|
|
503
|
+
service
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function permissionDeniedError(service, action) {
|
|
507
|
+
return {
|
|
508
|
+
code: ErrorCodes.PERMISSION_DENIED,
|
|
509
|
+
message: `Permission denied for action: ${action}`,
|
|
510
|
+
service
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function parseAuthError(responseText) {
|
|
514
|
+
const match = responseText.match(/^Unauthorized Action:\s*(.+?)\s*\/\s*(tinycloud\.\S+)$/m);
|
|
515
|
+
if (match) {
|
|
516
|
+
return { resource: match[1].trim(), action: match[2].trim() };
|
|
517
|
+
}
|
|
518
|
+
return {};
|
|
519
|
+
}
|
|
520
|
+
function authUnauthorizedError(service, message, meta) {
|
|
521
|
+
return serviceError(ErrorCodes.AUTH_UNAUTHORIZED, message, service, { meta });
|
|
522
|
+
}
|
|
523
|
+
function storageQuotaExceededError(service, message, meta) {
|
|
524
|
+
return {
|
|
525
|
+
code: ErrorCodes.STORAGE_QUOTA_EXCEEDED,
|
|
526
|
+
message,
|
|
527
|
+
service,
|
|
528
|
+
meta
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function storageLimitReachedError(service, message, meta) {
|
|
532
|
+
return {
|
|
533
|
+
code: ErrorCodes.STORAGE_LIMIT_REACHED,
|
|
534
|
+
message,
|
|
535
|
+
service,
|
|
536
|
+
meta
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function wrapError(service, error, defaultCode = ErrorCodes.NETWORK_ERROR) {
|
|
540
|
+
if (error instanceof Error) {
|
|
541
|
+
if (error.name === "AbortError") {
|
|
542
|
+
return abortedError(service);
|
|
543
|
+
}
|
|
544
|
+
if (error.name === "TimeoutError" || error.message.toLowerCase().includes("timeout")) {
|
|
545
|
+
return timeoutError(service);
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
code: defaultCode,
|
|
549
|
+
message: error.message,
|
|
550
|
+
service,
|
|
551
|
+
cause: error
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
code: defaultCode,
|
|
556
|
+
message: String(error),
|
|
557
|
+
service
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function errorResult(error) {
|
|
561
|
+
return err(error);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/base/BaseService.ts
|
|
565
|
+
var BaseService = class {
|
|
566
|
+
constructor() {
|
|
567
|
+
/**
|
|
568
|
+
* Abort controller for this service's operations.
|
|
569
|
+
* Reset on sign-out.
|
|
570
|
+
*/
|
|
571
|
+
this.abortController = new AbortController();
|
|
572
|
+
/**
|
|
573
|
+
* Service-specific configuration.
|
|
574
|
+
*/
|
|
575
|
+
this._config = {};
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Get the service configuration.
|
|
579
|
+
*/
|
|
580
|
+
get config() {
|
|
581
|
+
return this._config;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Initialize the service with context.
|
|
585
|
+
* Called by the SDK after instantiation.
|
|
586
|
+
*
|
|
587
|
+
* @param context - The service context
|
|
588
|
+
*/
|
|
589
|
+
initialize(context) {
|
|
590
|
+
this.context = context;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Called when session changes (sign-in, sign-out, refresh).
|
|
594
|
+
* Override in subclasses to handle session changes.
|
|
595
|
+
*
|
|
596
|
+
* @param session - The new session, or null if signed out
|
|
597
|
+
*/
|
|
598
|
+
onSessionChange(session) {
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Called when SDK signs out.
|
|
602
|
+
* Aborts all pending operations.
|
|
603
|
+
*/
|
|
604
|
+
onSignOut() {
|
|
605
|
+
this.abortController.abort();
|
|
606
|
+
this.abortController = new AbortController();
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Get the abort signal for this service.
|
|
610
|
+
* Combines the service-level abort with context-level abort.
|
|
611
|
+
*/
|
|
612
|
+
get abortSignal() {
|
|
613
|
+
return this.abortController.signal;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Check if the service is authenticated.
|
|
617
|
+
*/
|
|
618
|
+
get isAuthenticated() {
|
|
619
|
+
return this.context?.isAuthenticated ?? false;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Get the current session.
|
|
623
|
+
* Throws if not authenticated.
|
|
624
|
+
*/
|
|
625
|
+
get session() {
|
|
626
|
+
if (!this.context?.session) {
|
|
627
|
+
throw new Error("Not authenticated");
|
|
628
|
+
}
|
|
629
|
+
return this.context.session;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Check authentication and return error result if not authenticated.
|
|
633
|
+
* Use this at the start of methods that require authentication.
|
|
634
|
+
*
|
|
635
|
+
* @returns true if authenticated, false otherwise
|
|
636
|
+
*/
|
|
637
|
+
requireAuth() {
|
|
638
|
+
return this.isAuthenticated;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Emit a telemetry event.
|
|
642
|
+
*
|
|
643
|
+
* @param event - Event name
|
|
644
|
+
* @param data - Event data
|
|
645
|
+
*/
|
|
646
|
+
emit(event, data) {
|
|
647
|
+
this.context?.emit(event, data);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Emit a service request event.
|
|
651
|
+
*
|
|
652
|
+
* @param action - The action being performed
|
|
653
|
+
* @param key - Optional key/path being accessed
|
|
654
|
+
*/
|
|
655
|
+
emitRequest(action, key) {
|
|
656
|
+
this.emit(TelemetryEvents.SERVICE_REQUEST, {
|
|
657
|
+
service: this.getServiceName(),
|
|
658
|
+
action,
|
|
659
|
+
key,
|
|
660
|
+
timestamp: Date.now()
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Emit a service response event.
|
|
665
|
+
*
|
|
666
|
+
* @param action - The action that was performed
|
|
667
|
+
* @param ok - Whether the request was successful
|
|
668
|
+
* @param startTime - Start time for duration calculation
|
|
669
|
+
* @param status - Optional HTTP status code
|
|
670
|
+
*/
|
|
671
|
+
emitResponse(action, ok2, startTime, status) {
|
|
672
|
+
this.emit(TelemetryEvents.SERVICE_RESPONSE, {
|
|
673
|
+
service: this.getServiceName(),
|
|
674
|
+
action,
|
|
675
|
+
ok: ok2,
|
|
676
|
+
duration: Date.now() - startTime,
|
|
677
|
+
status
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Emit a service error event.
|
|
682
|
+
*
|
|
683
|
+
* @param error - The service error
|
|
684
|
+
*/
|
|
685
|
+
emitError(error) {
|
|
686
|
+
this.emit(TelemetryEvents.SERVICE_ERROR, {
|
|
687
|
+
service: this.getServiceName(),
|
|
688
|
+
error
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Get the service name from the static property.
|
|
693
|
+
* Subclasses must define static serviceName.
|
|
694
|
+
*/
|
|
695
|
+
getServiceName() {
|
|
696
|
+
return this.constructor.serviceName;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Create a combined abort signal from multiple sources.
|
|
700
|
+
*
|
|
701
|
+
* @param signals - Additional abort signals to combine
|
|
702
|
+
* @returns A combined abort signal
|
|
703
|
+
*/
|
|
704
|
+
combineSignals(...signals) {
|
|
705
|
+
const controller = new AbortController();
|
|
706
|
+
const allSignals = [this.abortSignal, ...signals.filter(Boolean)];
|
|
707
|
+
for (const signal of allSignals) {
|
|
708
|
+
if (signal.aborted) {
|
|
709
|
+
controller.abort(signal.reason);
|
|
710
|
+
return controller.signal;
|
|
711
|
+
}
|
|
712
|
+
signal.addEventListener("abort", () => controller.abort(signal.reason), {
|
|
713
|
+
once: true
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
return controller.signal;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Wrap an operation with error handling and telemetry.
|
|
720
|
+
*
|
|
721
|
+
* @param action - The action name for telemetry
|
|
722
|
+
* @param key - Optional key for telemetry
|
|
723
|
+
* @param operation - The operation to execute
|
|
724
|
+
* @returns Result of the operation
|
|
725
|
+
*/
|
|
726
|
+
async withTelemetry(action, key, operation) {
|
|
727
|
+
const startTime = Date.now();
|
|
728
|
+
this.emitRequest(action, key);
|
|
729
|
+
try {
|
|
730
|
+
const result = await operation();
|
|
731
|
+
if (result.ok) {
|
|
732
|
+
this.emitResponse(action, true, startTime);
|
|
733
|
+
} else {
|
|
734
|
+
this.emitResponse(action, false, startTime);
|
|
735
|
+
this.emitError(result.error);
|
|
736
|
+
}
|
|
737
|
+
return result;
|
|
738
|
+
} catch (error) {
|
|
739
|
+
const serviceError3 = wrapError(this.getServiceName(), error);
|
|
740
|
+
this.emitResponse(action, false, startTime);
|
|
741
|
+
this.emitError(serviceError3);
|
|
742
|
+
return err(serviceError3);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// src/kv/PrefixedKVService.ts
|
|
748
|
+
var PrefixedKVService = class _PrefixedKVService {
|
|
749
|
+
/**
|
|
750
|
+
* Create a new PrefixedKVService.
|
|
751
|
+
*
|
|
752
|
+
* @param kv - The underlying KV service to delegate to
|
|
753
|
+
* @param prefix - The prefix to apply to all operations
|
|
754
|
+
*/
|
|
755
|
+
constructor(kv, prefix) {
|
|
756
|
+
this._kv = kv;
|
|
757
|
+
this._prefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* The current prefix for this scoped view.
|
|
761
|
+
*/
|
|
762
|
+
get prefix() {
|
|
763
|
+
return this._prefix;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Compute the full key path by combining prefix and key.
|
|
767
|
+
*
|
|
768
|
+
* @param key - The key to prefix
|
|
769
|
+
* @returns The full path including prefix
|
|
770
|
+
*/
|
|
771
|
+
getFullKey(key) {
|
|
772
|
+
const normalizedKey = key.startsWith("/") ? key : `/${key}`;
|
|
773
|
+
return `${this._prefix}${normalizedKey}`;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Get a value by key.
|
|
777
|
+
*/
|
|
778
|
+
async get(key, options) {
|
|
779
|
+
const fullKey = this.getFullKey(key);
|
|
780
|
+
return this._kv.get(fullKey, { ...options, prefix: "" });
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Store a value at a key.
|
|
784
|
+
*/
|
|
785
|
+
async put(key, value, options) {
|
|
786
|
+
const fullKey = this.getFullKey(key);
|
|
787
|
+
return this._kv.put(fullKey, value, { ...options, prefix: "" });
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* List keys within this prefix.
|
|
791
|
+
*/
|
|
792
|
+
async list(options) {
|
|
793
|
+
const removePrefix = options?.removePrefix ?? true;
|
|
794
|
+
return this._kv.list({
|
|
795
|
+
...options,
|
|
796
|
+
prefix: this._prefix,
|
|
797
|
+
removePrefix
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Delete a key.
|
|
802
|
+
*/
|
|
803
|
+
async delete(key, options) {
|
|
804
|
+
const fullKey = this.getFullKey(key);
|
|
805
|
+
return this._kv.delete(fullKey, { ...options, prefix: "" });
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Get metadata for a key without retrieving the value.
|
|
809
|
+
*/
|
|
810
|
+
async head(key, options) {
|
|
811
|
+
const fullKey = this.getFullKey(key);
|
|
812
|
+
return this._kv.head(fullKey, { ...options, prefix: "" });
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Create a nested prefix-scoped view.
|
|
816
|
+
*/
|
|
817
|
+
withPrefix(subPrefix) {
|
|
818
|
+
const normalizedSubPrefix = subPrefix.startsWith("/") ? subPrefix : `/${subPrefix}`;
|
|
819
|
+
const combinedPrefix = `${this._prefix}${normalizedSubPrefix}`;
|
|
820
|
+
return new _PrefixedKVService(this._kv, combinedPrefix);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
// src/kv/types.ts
|
|
825
|
+
var KVAction = {
|
|
826
|
+
GET: "tinycloud.kv/get",
|
|
827
|
+
PUT: "tinycloud.kv/put",
|
|
828
|
+
LIST: "tinycloud.kv/list",
|
|
829
|
+
DELETE: "tinycloud.kv/del",
|
|
830
|
+
HEAD: "tinycloud.kv/metadata"
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
// src/kv/KVService.ts
|
|
834
|
+
var KVService = class extends BaseService {
|
|
835
|
+
/**
|
|
836
|
+
* Create a new KVService instance.
|
|
837
|
+
*
|
|
838
|
+
* @param config - Service configuration
|
|
839
|
+
*/
|
|
840
|
+
constructor(config = {}) {
|
|
841
|
+
super();
|
|
842
|
+
this._config = config;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Get the service configuration.
|
|
846
|
+
*/
|
|
847
|
+
get config() {
|
|
848
|
+
return this._config;
|
|
849
|
+
}
|
|
850
|
+
// Parses "Used: X bytes, Limit: Y bytes" from tinycloud-node error responses
|
|
851
|
+
parseQuotaInfo(errorText) {
|
|
852
|
+
const match = errorText.match(
|
|
853
|
+
/Used:\s*(\d+)\s*bytes,\s*Limit:\s*(\d+)\s*bytes/i
|
|
854
|
+
);
|
|
855
|
+
if (match) {
|
|
856
|
+
return {
|
|
857
|
+
usedBytes: parseInt(match[1], 10),
|
|
858
|
+
limitBytes: parseInt(match[2], 10)
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
return void 0;
|
|
862
|
+
}
|
|
863
|
+
handleQuotaErrorResponse(response, errorText, key) {
|
|
864
|
+
if (response.status === 402) {
|
|
865
|
+
const quotaInfo = this.parseQuotaInfo(errorText);
|
|
866
|
+
return err(
|
|
867
|
+
storageQuotaExceededError(
|
|
868
|
+
"kv",
|
|
869
|
+
`Storage quota exceeded for key "${key}": ${errorText}`,
|
|
870
|
+
{
|
|
871
|
+
status: response.status,
|
|
872
|
+
...quotaInfo ? { usedBytes: quotaInfo.usedBytes, limitBytes: quotaInfo.limitBytes } : {}
|
|
873
|
+
}
|
|
874
|
+
)
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
if (response.status === 413) {
|
|
878
|
+
const quotaInfo = this.parseQuotaInfo(errorText);
|
|
879
|
+
return err(
|
|
880
|
+
storageLimitReachedError(
|
|
881
|
+
"kv",
|
|
882
|
+
`Storage limit reached for key "${key}": ${errorText}`,
|
|
883
|
+
{
|
|
884
|
+
status: response.status,
|
|
885
|
+
...quotaInfo ? { usedBytes: quotaInfo.usedBytes, limitBytes: quotaInfo.limitBytes } : {}
|
|
886
|
+
}
|
|
887
|
+
)
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
return void 0;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Get the full path with optional prefix.
|
|
894
|
+
*
|
|
895
|
+
* @param key - The key
|
|
896
|
+
* @param prefixOverride - Optional prefix override
|
|
897
|
+
* @returns The full path
|
|
898
|
+
*/
|
|
899
|
+
getFullPath(key, prefixOverride) {
|
|
900
|
+
const prefix = prefixOverride ?? this._config.prefix ?? "";
|
|
901
|
+
return prefix ? `${prefix}/${key}` : key;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Get the host URL.
|
|
905
|
+
*/
|
|
906
|
+
get host() {
|
|
907
|
+
return this.context.hosts[0];
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Execute an invoke operation.
|
|
911
|
+
*
|
|
912
|
+
* @param path - Resource path
|
|
913
|
+
* @param action - KV action
|
|
914
|
+
* @param body - Optional request body
|
|
915
|
+
* @param signal - Optional abort signal
|
|
916
|
+
* @returns Fetch response
|
|
917
|
+
*/
|
|
918
|
+
async invokeOperation(path, action, body, signal) {
|
|
919
|
+
const session = this.context.session;
|
|
920
|
+
const headers = this.context.invoke(
|
|
921
|
+
session,
|
|
922
|
+
"kv",
|
|
923
|
+
path,
|
|
924
|
+
action
|
|
925
|
+
);
|
|
926
|
+
return this.context.fetch(`${this.host}/invoke`, {
|
|
927
|
+
method: "POST",
|
|
928
|
+
headers,
|
|
929
|
+
body,
|
|
930
|
+
signal: this.combineSignals(signal)
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Create KVResponseHeaders from fetch response headers.
|
|
935
|
+
*
|
|
936
|
+
* @param headers - Fetch response headers
|
|
937
|
+
* @returns KVResponseHeaders object
|
|
938
|
+
*/
|
|
939
|
+
createResponseHeaders(headers) {
|
|
940
|
+
return {
|
|
941
|
+
etag: headers.get("etag") ?? void 0,
|
|
942
|
+
contentType: headers.get("content-type") ?? void 0,
|
|
943
|
+
lastModified: headers.get("last-modified") ?? void 0,
|
|
944
|
+
contentLength: headers.get("content-length") ? parseInt(headers.get("content-length"), 10) : void 0,
|
|
945
|
+
get: (name) => headers.get(name)
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Parse response body based on content type.
|
|
950
|
+
*
|
|
951
|
+
* @param response - Fetch response
|
|
952
|
+
* @param raw - Whether to return raw text
|
|
953
|
+
* @returns Parsed data
|
|
954
|
+
*/
|
|
955
|
+
async parseResponse(response, raw = false) {
|
|
956
|
+
if (!response.ok) {
|
|
957
|
+
return void 0;
|
|
958
|
+
}
|
|
959
|
+
if (raw) {
|
|
960
|
+
return await response.text();
|
|
961
|
+
}
|
|
962
|
+
const contentType = response.headers.get("content-type");
|
|
963
|
+
if (contentType?.includes("application/json")) {
|
|
964
|
+
return await response.json();
|
|
965
|
+
} else if (contentType?.startsWith("text/")) {
|
|
966
|
+
return await response.text();
|
|
967
|
+
}
|
|
968
|
+
const text = await response.text();
|
|
969
|
+
if (!text) {
|
|
970
|
+
return void 0;
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
return JSON.parse(text);
|
|
974
|
+
} catch {
|
|
975
|
+
return text;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Get a value by key.
|
|
980
|
+
*/
|
|
981
|
+
async get(key, options) {
|
|
982
|
+
return this.withTelemetry("get", key, async () => {
|
|
983
|
+
if (!this.requireAuth()) {
|
|
984
|
+
return err(authRequiredError("kv"));
|
|
985
|
+
}
|
|
986
|
+
const path = this.getFullPath(key, options?.prefix);
|
|
987
|
+
try {
|
|
988
|
+
const response = await this.invokeOperation(
|
|
989
|
+
path,
|
|
990
|
+
KVAction.GET,
|
|
991
|
+
void 0,
|
|
992
|
+
options?.signal
|
|
993
|
+
);
|
|
994
|
+
if (!response.ok) {
|
|
995
|
+
if (response.status === 401) {
|
|
996
|
+
const errorText2 = await response.text();
|
|
997
|
+
const { resource, action } = parseAuthError(errorText2);
|
|
998
|
+
return err(authUnauthorizedError("kv", errorText2, {
|
|
999
|
+
status: response.status,
|
|
1000
|
+
...action && { requiredAction: action },
|
|
1001
|
+
...resource && { resource }
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
if (response.status === 404) {
|
|
1005
|
+
return err(
|
|
1006
|
+
serviceError(
|
|
1007
|
+
ErrorCodes.KV_NOT_FOUND,
|
|
1008
|
+
`Key not found: ${key}`,
|
|
1009
|
+
"kv"
|
|
1010
|
+
)
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
const errorText = await response.text();
|
|
1014
|
+
return err(
|
|
1015
|
+
serviceError(
|
|
1016
|
+
ErrorCodes.NETWORK_ERROR,
|
|
1017
|
+
`Failed to get key "${key}": ${response.status} - ${errorText}`,
|
|
1018
|
+
"kv",
|
|
1019
|
+
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1020
|
+
)
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
const data = await this.parseResponse(response, options?.raw);
|
|
1024
|
+
return ok({
|
|
1025
|
+
data,
|
|
1026
|
+
headers: this.createResponseHeaders(response.headers)
|
|
1027
|
+
});
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
return err(wrapError("kv", error));
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Store a value at a key.
|
|
1035
|
+
*/
|
|
1036
|
+
async put(key, value, options) {
|
|
1037
|
+
return this.withTelemetry("put", key, async () => {
|
|
1038
|
+
if (!this.requireAuth()) {
|
|
1039
|
+
return err(authRequiredError("kv"));
|
|
1040
|
+
}
|
|
1041
|
+
const path = this.getFullPath(key, options?.prefix);
|
|
1042
|
+
let body;
|
|
1043
|
+
if (typeof value === "string") {
|
|
1044
|
+
body = value;
|
|
1045
|
+
} else {
|
|
1046
|
+
body = JSON.stringify(value);
|
|
1047
|
+
}
|
|
1048
|
+
try {
|
|
1049
|
+
const response = await this.invokeOperation(
|
|
1050
|
+
path,
|
|
1051
|
+
KVAction.PUT,
|
|
1052
|
+
body,
|
|
1053
|
+
options?.signal
|
|
1054
|
+
);
|
|
1055
|
+
if (!response.ok) {
|
|
1056
|
+
if (response.status === 401) {
|
|
1057
|
+
const errorText2 = await response.text();
|
|
1058
|
+
const { resource, action } = parseAuthError(errorText2);
|
|
1059
|
+
return err(authUnauthorizedError("kv", errorText2, {
|
|
1060
|
+
status: response.status,
|
|
1061
|
+
...action && { requiredAction: action },
|
|
1062
|
+
...resource && { resource }
|
|
1063
|
+
}));
|
|
1064
|
+
}
|
|
1065
|
+
const errorText = await response.text();
|
|
1066
|
+
const quotaError = this.handleQuotaErrorResponse(
|
|
1067
|
+
response,
|
|
1068
|
+
errorText,
|
|
1069
|
+
key
|
|
1070
|
+
);
|
|
1071
|
+
if (quotaError) {
|
|
1072
|
+
return quotaError;
|
|
1073
|
+
}
|
|
1074
|
+
return err(
|
|
1075
|
+
serviceError(
|
|
1076
|
+
ErrorCodes.KV_WRITE_FAILED,
|
|
1077
|
+
`Failed to put key "${key}": ${response.status} - ${errorText}`,
|
|
1078
|
+
"kv",
|
|
1079
|
+
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1080
|
+
)
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
return ok({
|
|
1084
|
+
data: void 0,
|
|
1085
|
+
headers: this.createResponseHeaders(response.headers)
|
|
1086
|
+
});
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
return err(wrapError("kv", error));
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* List keys with optional prefix filtering.
|
|
1094
|
+
*/
|
|
1095
|
+
async list(options) {
|
|
1096
|
+
return this.withTelemetry("list", options?.prefix, async () => {
|
|
1097
|
+
if (!this.requireAuth()) {
|
|
1098
|
+
return err(authRequiredError("kv"));
|
|
1099
|
+
}
|
|
1100
|
+
let listPath = options?.prefix ?? this._config.prefix ?? "";
|
|
1101
|
+
if (options?.path) {
|
|
1102
|
+
listPath = listPath ? `${listPath}/${options.path}` : options.path;
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
const response = await this.invokeOperation(
|
|
1106
|
+
listPath,
|
|
1107
|
+
KVAction.LIST,
|
|
1108
|
+
void 0,
|
|
1109
|
+
options?.signal
|
|
1110
|
+
);
|
|
1111
|
+
if (!response.ok) {
|
|
1112
|
+
if (response.status === 401) {
|
|
1113
|
+
const errorText2 = await response.text();
|
|
1114
|
+
const { resource, action } = parseAuthError(errorText2);
|
|
1115
|
+
return err(authUnauthorizedError("kv", errorText2, {
|
|
1116
|
+
status: response.status,
|
|
1117
|
+
...action && { requiredAction: action },
|
|
1118
|
+
...resource && { resource }
|
|
1119
|
+
}));
|
|
1120
|
+
}
|
|
1121
|
+
const errorText = await response.text();
|
|
1122
|
+
return err(
|
|
1123
|
+
serviceError(
|
|
1124
|
+
ErrorCodes.NETWORK_ERROR,
|
|
1125
|
+
`Failed to list keys: ${response.status} - ${errorText}`,
|
|
1126
|
+
"kv",
|
|
1127
|
+
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1128
|
+
)
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
let keys = await this.parseResponse(response, options?.raw);
|
|
1132
|
+
keys = keys ?? [];
|
|
1133
|
+
if (options?.removePrefix && listPath) {
|
|
1134
|
+
const prefixWithSlash = listPath.endsWith("/") ? listPath : `${listPath}/`;
|
|
1135
|
+
keys = keys.map(
|
|
1136
|
+
(key) => key.startsWith(prefixWithSlash) ? key.slice(prefixWithSlash.length) : key
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
return ok({ keys });
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
return err(wrapError("kv", error));
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Delete a key.
|
|
1147
|
+
*/
|
|
1148
|
+
async delete(key, options) {
|
|
1149
|
+
return this.withTelemetry("delete", key, async () => {
|
|
1150
|
+
if (!this.requireAuth()) {
|
|
1151
|
+
return err(authRequiredError("kv"));
|
|
1152
|
+
}
|
|
1153
|
+
const path = this.getFullPath(key, options?.prefix);
|
|
1154
|
+
try {
|
|
1155
|
+
const response = await this.invokeOperation(
|
|
1156
|
+
path,
|
|
1157
|
+
KVAction.DELETE,
|
|
1158
|
+
void 0,
|
|
1159
|
+
options?.signal
|
|
1160
|
+
);
|
|
1161
|
+
if (!response.ok) {
|
|
1162
|
+
if (response.status === 401) {
|
|
1163
|
+
const errorText2 = await response.text();
|
|
1164
|
+
const { resource, action } = parseAuthError(errorText2);
|
|
1165
|
+
return err(authUnauthorizedError("kv", errorText2, {
|
|
1166
|
+
status: response.status,
|
|
1167
|
+
...action && { requiredAction: action },
|
|
1168
|
+
...resource && { resource }
|
|
1169
|
+
}));
|
|
1170
|
+
}
|
|
1171
|
+
if (response.status === 404) {
|
|
1172
|
+
return err(
|
|
1173
|
+
serviceError(
|
|
1174
|
+
ErrorCodes.KV_NOT_FOUND,
|
|
1175
|
+
`Key not found: ${key}`,
|
|
1176
|
+
"kv"
|
|
1177
|
+
)
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
const errorText = await response.text();
|
|
1181
|
+
return err(
|
|
1182
|
+
serviceError(
|
|
1183
|
+
ErrorCodes.NETWORK_ERROR,
|
|
1184
|
+
`Failed to delete key "${key}": ${response.status} - ${errorText}`,
|
|
1185
|
+
"kv",
|
|
1186
|
+
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1187
|
+
)
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
return ok(void 0);
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
return err(wrapError("kv", error));
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Get metadata for a key without retrieving the value.
|
|
1198
|
+
*/
|
|
1199
|
+
async head(key, options) {
|
|
1200
|
+
return this.withTelemetry("head", key, async () => {
|
|
1201
|
+
if (!this.requireAuth()) {
|
|
1202
|
+
return err(authRequiredError("kv"));
|
|
1203
|
+
}
|
|
1204
|
+
const path = this.getFullPath(key, options?.prefix);
|
|
1205
|
+
try {
|
|
1206
|
+
const response = await this.invokeOperation(
|
|
1207
|
+
path,
|
|
1208
|
+
KVAction.HEAD,
|
|
1209
|
+
void 0,
|
|
1210
|
+
options?.signal
|
|
1211
|
+
);
|
|
1212
|
+
if (!response.ok) {
|
|
1213
|
+
if (response.status === 401) {
|
|
1214
|
+
const errorText2 = await response.text();
|
|
1215
|
+
const { resource, action } = parseAuthError(errorText2);
|
|
1216
|
+
return err(authUnauthorizedError("kv", errorText2, {
|
|
1217
|
+
status: response.status,
|
|
1218
|
+
...action && { requiredAction: action },
|
|
1219
|
+
...resource && { resource }
|
|
1220
|
+
}));
|
|
1221
|
+
}
|
|
1222
|
+
if (response.status === 404) {
|
|
1223
|
+
return err(
|
|
1224
|
+
serviceError(
|
|
1225
|
+
ErrorCodes.KV_NOT_FOUND,
|
|
1226
|
+
`Key not found: ${key}`,
|
|
1227
|
+
"kv"
|
|
1228
|
+
)
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
const errorText = await response.text();
|
|
1232
|
+
return err(
|
|
1233
|
+
serviceError(
|
|
1234
|
+
ErrorCodes.NETWORK_ERROR,
|
|
1235
|
+
`Failed to get metadata for key "${key}": ${response.status} - ${errorText}`,
|
|
1236
|
+
"kv",
|
|
1237
|
+
{ meta: { status: response.status, statusText: response.statusText } }
|
|
1238
|
+
)
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
return ok({
|
|
1242
|
+
data: void 0,
|
|
1243
|
+
headers: this.createResponseHeaders(response.headers)
|
|
1244
|
+
});
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
return err(wrapError("kv", error));
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Create a prefix-scoped view of this KV service.
|
|
1252
|
+
*
|
|
1253
|
+
* Returns a PrefixedKVService that automatically prefixes all
|
|
1254
|
+
* key operations with the specified prefix. This enables apps
|
|
1255
|
+
* to isolate their data within a shared space.
|
|
1256
|
+
*
|
|
1257
|
+
* @param prefix - The prefix to apply to all operations
|
|
1258
|
+
* @returns A PrefixedKVService scoped to the prefix
|
|
1259
|
+
*
|
|
1260
|
+
* ## Prefix Conventions
|
|
1261
|
+
*
|
|
1262
|
+
* | Pattern | Use Case | Example |
|
|
1263
|
+
* | -- | -- | -- |
|
|
1264
|
+
* | `/app.{domain}/` | App-private data | `/app.photos.xyz/settings.json` |
|
|
1265
|
+
* | `/{type}/` | Shared data type | `/photos/vacation.jpg` |
|
|
1266
|
+
* | `/.{name}/` | Hidden/system data | `/.cache/thumbnails/` |
|
|
1267
|
+
* | `/public/` | Explicitly shareable | `/public/profile.json` |
|
|
1268
|
+
*
|
|
1269
|
+
* @example
|
|
1270
|
+
* ```typescript
|
|
1271
|
+
* const space = sdk.space('default');
|
|
1272
|
+
*
|
|
1273
|
+
* // Create prefix-scoped views
|
|
1274
|
+
* const myApp = space.kv.withPrefix('/app.myapp.com');
|
|
1275
|
+
* const sharedPhotos = space.kv.withPrefix('/photos');
|
|
1276
|
+
*
|
|
1277
|
+
* // Operations are automatically prefixed
|
|
1278
|
+
* await myApp.put('settings.json', { theme: 'dark' });
|
|
1279
|
+
* // -> Actually writes to: /app.myapp.com/settings.json
|
|
1280
|
+
*
|
|
1281
|
+
* await myApp.get('settings.json');
|
|
1282
|
+
* // -> Actually reads from: /app.myapp.com/settings.json
|
|
1283
|
+
*
|
|
1284
|
+
* await sharedPhotos.list();
|
|
1285
|
+
* // -> Lists: /photos/*
|
|
1286
|
+
*
|
|
1287
|
+
* // Nested prefixes
|
|
1288
|
+
* const settings = myApp.withPrefix('/settings');
|
|
1289
|
+
* await settings.get('theme.json'); // -> /app.myapp.com/settings/theme.json
|
|
1290
|
+
* ```
|
|
1291
|
+
*/
|
|
1292
|
+
withPrefix(prefix) {
|
|
1293
|
+
return new PrefixedKVService(this, prefix);
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1
1296
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Platform-agnostic services with plugin architecture for TinyCloud.
|
|
5
|
-
*
|
|
6
|
-
* @packageDocumentation
|
|
7
|
-
* @module @tinycloud/sdk-services
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* ```typescript
|
|
11
|
-
* import {
|
|
12
|
-
* ServiceContext,
|
|
13
|
-
* BaseService,
|
|
14
|
-
* Result,
|
|
15
|
-
* ErrorCodes,
|
|
16
|
-
* } from '@tinycloud/sdk-services';
|
|
17
|
-
*
|
|
18
|
-
* // Create a context
|
|
19
|
-
* const context = new ServiceContext({
|
|
20
|
-
* invoke: wasmInvoke,
|
|
21
|
-
* hosts: ['https://node.tinycloud.xyz'],
|
|
22
|
-
* });
|
|
23
|
-
*
|
|
24
|
-
* // Create and register a service
|
|
25
|
-
* const kv = new KVService({ prefix: 'myapp' });
|
|
26
|
-
* context.registerService('kv', kv);
|
|
27
|
-
* kv.initialize(context);
|
|
28
|
-
*
|
|
29
|
-
* // Use the service
|
|
30
|
-
* const result = await kv.get('key');
|
|
31
|
-
* if (result.ok) {
|
|
32
|
-
* console.log(result.data);
|
|
33
|
-
* }
|
|
34
|
-
* ```
|
|
1297
|
+
* Service identifier for registration.
|
|
35
1298
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
export {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
1299
|
+
KVService.serviceName = "kv";
|
|
1300
|
+
|
|
1301
|
+
// src/sql/DatabaseHandle.ts
|
|
1302
|
+
var DatabaseHandle = class {
|
|
1303
|
+
constructor(service, name) {
|
|
1304
|
+
this.service = service;
|
|
1305
|
+
this.name = name;
|
|
1306
|
+
}
|
|
1307
|
+
async query(sql, params, options) {
|
|
1308
|
+
return this.service.queryOnDb(this.name, sql, params, options);
|
|
1309
|
+
}
|
|
1310
|
+
async execute(sql, params, options) {
|
|
1311
|
+
return this.service.executeOnDb(this.name, sql, params, options);
|
|
1312
|
+
}
|
|
1313
|
+
async batch(statements, options) {
|
|
1314
|
+
return this.service.batchOnDb(this.name, statements, options);
|
|
1315
|
+
}
|
|
1316
|
+
async executeStatement(name, params, options) {
|
|
1317
|
+
return this.service.executeStatementOnDb(this.name, name, params, options);
|
|
1318
|
+
}
|
|
1319
|
+
async export(options) {
|
|
1320
|
+
return this.service.exportDb(this.name, options);
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
// src/sql/types.ts
|
|
1325
|
+
var SQLAction = {
|
|
1326
|
+
READ: "tinycloud.sql/read",
|
|
1327
|
+
WRITE: "tinycloud.sql/write",
|
|
1328
|
+
ADMIN: "tinycloud.sql/admin",
|
|
1329
|
+
SELECT: "tinycloud.sql/select",
|
|
1330
|
+
INSERT: "tinycloud.sql/insert",
|
|
1331
|
+
UPDATE: "tinycloud.sql/update",
|
|
1332
|
+
DELETE: "tinycloud.sql/delete",
|
|
1333
|
+
EXECUTE: "tinycloud.sql/execute",
|
|
1334
|
+
EXPORT: "tinycloud.sql/export",
|
|
1335
|
+
ALL: "tinycloud.sql/*"
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
// src/sql/SQLService.ts
|
|
1339
|
+
var SQLService = class extends BaseService {
|
|
1340
|
+
constructor(config = {}) {
|
|
1341
|
+
super();
|
|
1342
|
+
this._config = config;
|
|
1343
|
+
}
|
|
1344
|
+
get config() {
|
|
1345
|
+
return this._config;
|
|
1346
|
+
}
|
|
1347
|
+
get defaultDbName() {
|
|
1348
|
+
return this._config.defaultDatabase ?? "default";
|
|
1349
|
+
}
|
|
1350
|
+
get host() {
|
|
1351
|
+
return this.context.hosts[0];
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Get a handle to a named database.
|
|
1355
|
+
*/
|
|
1356
|
+
db(name) {
|
|
1357
|
+
return new DatabaseHandle(this, name ?? this.defaultDbName);
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Shortcut: query the default database.
|
|
1361
|
+
*/
|
|
1362
|
+
async query(sql, params, options) {
|
|
1363
|
+
return this.queryOnDb(this.defaultDbName, sql, params, options);
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Shortcut: execute on the default database.
|
|
1367
|
+
*/
|
|
1368
|
+
async execute(sql, params, options) {
|
|
1369
|
+
return this.executeOnDb(this.defaultDbName, sql, params, options);
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Shortcut: batch on the default database.
|
|
1373
|
+
*/
|
|
1374
|
+
async batch(statements, options) {
|
|
1375
|
+
return this.batchOnDb(this.defaultDbName, statements, options);
|
|
1376
|
+
}
|
|
1377
|
+
// === Internal methods called by DatabaseHandle ===
|
|
1378
|
+
async queryOnDb(dbName, sql, params, options) {
|
|
1379
|
+
return this.withTelemetry("query", dbName, async () => {
|
|
1380
|
+
if (!this.requireAuth()) {
|
|
1381
|
+
return err(authRequiredError("sql"));
|
|
1382
|
+
}
|
|
1383
|
+
try {
|
|
1384
|
+
const response = await this.invokeSQL(
|
|
1385
|
+
dbName,
|
|
1386
|
+
SQLAction.READ,
|
|
1387
|
+
{ action: "query", sql, params: params ?? [] },
|
|
1388
|
+
options?.signal
|
|
1389
|
+
);
|
|
1390
|
+
if (!response.ok) {
|
|
1391
|
+
return this.handleErrorResponse(response, "query");
|
|
1392
|
+
}
|
|
1393
|
+
const data = await response.json();
|
|
1394
|
+
return ok(data);
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
return err(wrapError("sql", error));
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
async executeOnDb(dbName, sql, params, options) {
|
|
1401
|
+
return this.withTelemetry("execute", dbName, async () => {
|
|
1402
|
+
if (!this.requireAuth()) {
|
|
1403
|
+
return err(authRequiredError("sql"));
|
|
1404
|
+
}
|
|
1405
|
+
try {
|
|
1406
|
+
const body = {
|
|
1407
|
+
action: "execute",
|
|
1408
|
+
sql,
|
|
1409
|
+
params: params ?? []
|
|
1410
|
+
};
|
|
1411
|
+
if (options?.schema) {
|
|
1412
|
+
body.schema = options.schema;
|
|
1413
|
+
}
|
|
1414
|
+
const response = await this.invokeSQL(
|
|
1415
|
+
dbName,
|
|
1416
|
+
SQLAction.WRITE,
|
|
1417
|
+
body,
|
|
1418
|
+
options?.signal
|
|
1419
|
+
);
|
|
1420
|
+
if (!response.ok) {
|
|
1421
|
+
return this.handleErrorResponse(response, "execute");
|
|
1422
|
+
}
|
|
1423
|
+
const data = await response.json();
|
|
1424
|
+
return ok(data);
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
return err(wrapError("sql", error));
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
async batchOnDb(dbName, statements, options) {
|
|
1431
|
+
return this.withTelemetry("batch", dbName, async () => {
|
|
1432
|
+
if (!this.requireAuth()) {
|
|
1433
|
+
return err(authRequiredError("sql"));
|
|
1434
|
+
}
|
|
1435
|
+
try {
|
|
1436
|
+
const response = await this.invokeSQL(
|
|
1437
|
+
dbName,
|
|
1438
|
+
SQLAction.WRITE,
|
|
1439
|
+
{ action: "batch", statements },
|
|
1440
|
+
options?.signal
|
|
1441
|
+
);
|
|
1442
|
+
if (!response.ok) {
|
|
1443
|
+
return this.handleErrorResponse(response, "batch");
|
|
1444
|
+
}
|
|
1445
|
+
const data = await response.json();
|
|
1446
|
+
return ok(data);
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
return err(wrapError("sql", error));
|
|
1449
|
+
}
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
async executeStatementOnDb(dbName, name, params, options) {
|
|
1453
|
+
return this.withTelemetry("executeStatement", dbName, async () => {
|
|
1454
|
+
if (!this.requireAuth()) {
|
|
1455
|
+
return err(authRequiredError("sql"));
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
const response = await this.invokeSQL(
|
|
1459
|
+
dbName,
|
|
1460
|
+
SQLAction.EXECUTE,
|
|
1461
|
+
{ action: "execute_statement", name, params: params ?? [] },
|
|
1462
|
+
options?.signal
|
|
1463
|
+
);
|
|
1464
|
+
if (!response.ok) {
|
|
1465
|
+
return this.handleErrorResponse(response, "executeStatement");
|
|
1466
|
+
}
|
|
1467
|
+
const data = await response.json();
|
|
1468
|
+
return ok(data);
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
return err(wrapError("sql", error));
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
async exportDb(dbName, options) {
|
|
1475
|
+
return this.withTelemetry("export", dbName, async () => {
|
|
1476
|
+
if (!this.requireAuth()) {
|
|
1477
|
+
return err(authRequiredError("sql"));
|
|
1478
|
+
}
|
|
1479
|
+
try {
|
|
1480
|
+
const response = await this.invokeSQL(
|
|
1481
|
+
dbName,
|
|
1482
|
+
SQLAction.EXPORT,
|
|
1483
|
+
{ action: "export" },
|
|
1484
|
+
options?.signal
|
|
1485
|
+
);
|
|
1486
|
+
if (!response.ok) {
|
|
1487
|
+
return this.handleErrorResponse(response, "export");
|
|
1488
|
+
}
|
|
1489
|
+
const resp = response;
|
|
1490
|
+
if (typeof resp.blob === "function") {
|
|
1491
|
+
const blob = await resp.blob();
|
|
1492
|
+
return ok(blob);
|
|
1493
|
+
}
|
|
1494
|
+
const text = await response.text();
|
|
1495
|
+
return ok(text);
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
return err(wrapError("sql", error));
|
|
1498
|
+
}
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
// === Private helpers ===
|
|
1502
|
+
async invokeSQL(dbName, action, body, signal) {
|
|
1503
|
+
const session = this.context.session;
|
|
1504
|
+
const headers = this.context.invoke(session, "sql", dbName, action);
|
|
1505
|
+
return this.context.fetch(`${this.host}/invoke`, {
|
|
1506
|
+
method: "POST",
|
|
1507
|
+
headers: {
|
|
1508
|
+
...headers,
|
|
1509
|
+
"Content-Type": "application/json"
|
|
1510
|
+
},
|
|
1511
|
+
body: JSON.stringify(body),
|
|
1512
|
+
signal: this.combineSignals(signal)
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
async handleErrorResponse(response, operation) {
|
|
1516
|
+
const errorText = await response.text();
|
|
1517
|
+
let errorBody = {};
|
|
1518
|
+
try {
|
|
1519
|
+
errorBody = JSON.parse(errorText);
|
|
1520
|
+
} catch {
|
|
1521
|
+
}
|
|
1522
|
+
const errorCode = this.mapHttpStatusToErrorCode(
|
|
1523
|
+
response.status,
|
|
1524
|
+
errorBody.error
|
|
1525
|
+
);
|
|
1526
|
+
const message = errorBody.message || `SQL ${operation} failed: ${response.status} - ${errorText}`;
|
|
1527
|
+
const meta = { status: response.status, statusText: response.statusText };
|
|
1528
|
+
if (response.status === 401) {
|
|
1529
|
+
const { resource, action } = parseAuthError(errorText);
|
|
1530
|
+
if (action) meta.requiredAction = action;
|
|
1531
|
+
if (resource) meta.resource = resource;
|
|
1532
|
+
}
|
|
1533
|
+
return err(
|
|
1534
|
+
serviceError(errorCode, message, "sql", { meta })
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
mapHttpStatusToErrorCode(status, serverError) {
|
|
1538
|
+
switch (status) {
|
|
1539
|
+
case 400:
|
|
1540
|
+
return ErrorCodes.SQL_ERROR;
|
|
1541
|
+
case 401:
|
|
1542
|
+
return ErrorCodes.AUTH_UNAUTHORIZED;
|
|
1543
|
+
case 403:
|
|
1544
|
+
if (serverError === "sql_readonly_violation") {
|
|
1545
|
+
return ErrorCodes.SQL_READONLY_VIOLATION;
|
|
1546
|
+
}
|
|
1547
|
+
return ErrorCodes.SQL_PERMISSION_DENIED;
|
|
1548
|
+
case 404:
|
|
1549
|
+
return ErrorCodes.SQL_DATABASE_NOT_FOUND;
|
|
1550
|
+
case 413:
|
|
1551
|
+
return ErrorCodes.SQL_RESPONSE_TOO_LARGE;
|
|
1552
|
+
case 429:
|
|
1553
|
+
return ErrorCodes.SQL_QUOTA_EXCEEDED;
|
|
1554
|
+
default:
|
|
1555
|
+
return ErrorCodes.NETWORK_ERROR;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
SQLService.serviceName = "sql";
|
|
1560
|
+
|
|
1561
|
+
// src/duckdb/DuckDbDatabaseHandle.ts
|
|
1562
|
+
var DuckDbDatabaseHandle = class {
|
|
1563
|
+
constructor(service, name) {
|
|
1564
|
+
this.service = service;
|
|
1565
|
+
this.name = name;
|
|
1566
|
+
}
|
|
1567
|
+
async query(sql, params, options) {
|
|
1568
|
+
return this.service.queryOnDb(this.name, sql, params, options);
|
|
1569
|
+
}
|
|
1570
|
+
async queryArrow(sql, params, options) {
|
|
1571
|
+
return this.service.queryArrowOnDb(this.name, sql, params, options);
|
|
1572
|
+
}
|
|
1573
|
+
async execute(sql, params, options) {
|
|
1574
|
+
return this.service.executeOnDb(this.name, sql, params, options);
|
|
1575
|
+
}
|
|
1576
|
+
async batch(statements, options) {
|
|
1577
|
+
return this.service.batchOnDb(this.name, statements, options);
|
|
1578
|
+
}
|
|
1579
|
+
async executeStatement(name, params, options) {
|
|
1580
|
+
return this.service.executeStatementOnDb(this.name, name, params, options);
|
|
1581
|
+
}
|
|
1582
|
+
async describe(options) {
|
|
1583
|
+
return this.service.describeDb(this.name, options);
|
|
1584
|
+
}
|
|
1585
|
+
async export(options) {
|
|
1586
|
+
return this.service.exportOnDb(this.name, options);
|
|
1587
|
+
}
|
|
1588
|
+
async import(data, options) {
|
|
1589
|
+
return this.service.importOnDb(this.name, data, options);
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
// src/duckdb/types.ts
|
|
1594
|
+
var DuckDbAction = {
|
|
1595
|
+
READ: "tinycloud.duckdb/read",
|
|
1596
|
+
WRITE: "tinycloud.duckdb/write",
|
|
1597
|
+
ADMIN: "tinycloud.duckdb/admin",
|
|
1598
|
+
DESCRIBE: "tinycloud.duckdb/describe",
|
|
1599
|
+
EXPORT: "tinycloud.duckdb/export",
|
|
1600
|
+
IMPORT: "tinycloud.duckdb/import",
|
|
1601
|
+
EXECUTE: "tinycloud.duckdb/execute",
|
|
1602
|
+
ALL: "tinycloud.duckdb/*"
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
// src/duckdb/DuckDbService.ts
|
|
1606
|
+
var DuckDbService = class extends BaseService {
|
|
1607
|
+
constructor(config = {}) {
|
|
1608
|
+
super();
|
|
1609
|
+
this._config = config;
|
|
1610
|
+
}
|
|
1611
|
+
get config() {
|
|
1612
|
+
return this._config;
|
|
1613
|
+
}
|
|
1614
|
+
get defaultDbName() {
|
|
1615
|
+
return this._config.defaultDatabase ?? "default";
|
|
1616
|
+
}
|
|
1617
|
+
get host() {
|
|
1618
|
+
return this.context.hosts[0];
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Get a handle to a named database.
|
|
1622
|
+
*/
|
|
1623
|
+
db(name) {
|
|
1624
|
+
return new DuckDbDatabaseHandle(this, name ?? this.defaultDbName);
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Shortcut: query the default database (JSON format).
|
|
1628
|
+
*/
|
|
1629
|
+
async query(sql, params, options) {
|
|
1630
|
+
return this.queryOnDb(this.defaultDbName, sql, params, options);
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Shortcut: query the default database (Arrow IPC format).
|
|
1634
|
+
*/
|
|
1635
|
+
async queryArrow(sql, params, options) {
|
|
1636
|
+
return this.queryArrowOnDb(this.defaultDbName, sql, params, options);
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Shortcut: execute on the default database.
|
|
1640
|
+
*/
|
|
1641
|
+
async execute(sql, params, options) {
|
|
1642
|
+
return this.executeOnDb(this.defaultDbName, sql, params, options);
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Shortcut: batch on the default database.
|
|
1646
|
+
*/
|
|
1647
|
+
async batch(statements, options) {
|
|
1648
|
+
return this.batchOnDb(this.defaultDbName, statements, options);
|
|
1649
|
+
}
|
|
1650
|
+
// === Internal methods called by DuckDbDatabaseHandle ===
|
|
1651
|
+
async queryOnDb(dbName, sql, params, options) {
|
|
1652
|
+
return this.withTelemetry("query", dbName, async () => {
|
|
1653
|
+
if (!this.requireAuth()) {
|
|
1654
|
+
return err(authRequiredError("duckdb"));
|
|
1655
|
+
}
|
|
1656
|
+
try {
|
|
1657
|
+
const response = await this.invokeDuckDb(
|
|
1658
|
+
dbName,
|
|
1659
|
+
DuckDbAction.READ,
|
|
1660
|
+
{ action: "query", sql, params: params ?? [] },
|
|
1661
|
+
options?.signal
|
|
1662
|
+
);
|
|
1663
|
+
if (!response.ok) {
|
|
1664
|
+
return this.handleErrorResponse(response, "query");
|
|
1665
|
+
}
|
|
1666
|
+
const data = await response.json();
|
|
1667
|
+
return ok(data);
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
return err(wrapError("duckdb", error));
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
async queryArrowOnDb(dbName, sql, params, options) {
|
|
1674
|
+
return this.withTelemetry("queryArrow", dbName, async () => {
|
|
1675
|
+
if (!this.requireAuth()) {
|
|
1676
|
+
return err(authRequiredError("duckdb"));
|
|
1677
|
+
}
|
|
1678
|
+
try {
|
|
1679
|
+
const response = await this.invokeDuckDb(
|
|
1680
|
+
dbName,
|
|
1681
|
+
DuckDbAction.READ,
|
|
1682
|
+
{ action: "query", sql, params: params ?? [] },
|
|
1683
|
+
options?.signal,
|
|
1684
|
+
{ Accept: "application/vnd.apache.arrow.stream" }
|
|
1685
|
+
);
|
|
1686
|
+
if (!response.ok) {
|
|
1687
|
+
return this.handleErrorResponse(response, "queryArrow");
|
|
1688
|
+
}
|
|
1689
|
+
const buffer = await response.arrayBuffer();
|
|
1690
|
+
return ok(buffer);
|
|
1691
|
+
} catch (error) {
|
|
1692
|
+
return err(wrapError("duckdb", error));
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
async executeOnDb(dbName, sql, params, options) {
|
|
1697
|
+
return this.withTelemetry("execute", dbName, async () => {
|
|
1698
|
+
if (!this.requireAuth()) {
|
|
1699
|
+
return err(authRequiredError("duckdb"));
|
|
1700
|
+
}
|
|
1701
|
+
try {
|
|
1702
|
+
const body = {
|
|
1703
|
+
action: "execute",
|
|
1704
|
+
sql,
|
|
1705
|
+
params: params ?? []
|
|
1706
|
+
};
|
|
1707
|
+
if (options?.schema) {
|
|
1708
|
+
body.schema = options.schema;
|
|
1709
|
+
}
|
|
1710
|
+
const response = await this.invokeDuckDb(
|
|
1711
|
+
dbName,
|
|
1712
|
+
DuckDbAction.WRITE,
|
|
1713
|
+
body,
|
|
1714
|
+
options?.signal
|
|
1715
|
+
);
|
|
1716
|
+
if (!response.ok) {
|
|
1717
|
+
return this.handleErrorResponse(response, "execute");
|
|
1718
|
+
}
|
|
1719
|
+
const data = await response.json();
|
|
1720
|
+
return ok(data);
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
return err(wrapError("duckdb", error));
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
async batchOnDb(dbName, statements, options) {
|
|
1727
|
+
return this.withTelemetry("batch", dbName, async () => {
|
|
1728
|
+
if (!this.requireAuth()) {
|
|
1729
|
+
return err(authRequiredError("duckdb"));
|
|
1730
|
+
}
|
|
1731
|
+
try {
|
|
1732
|
+
const body = {
|
|
1733
|
+
action: "batch",
|
|
1734
|
+
statements
|
|
1735
|
+
};
|
|
1736
|
+
if (options?.transactional !== void 0) {
|
|
1737
|
+
body.transactional = options.transactional;
|
|
1738
|
+
}
|
|
1739
|
+
const response = await this.invokeDuckDb(
|
|
1740
|
+
dbName,
|
|
1741
|
+
DuckDbAction.WRITE,
|
|
1742
|
+
body,
|
|
1743
|
+
options?.signal
|
|
1744
|
+
);
|
|
1745
|
+
if (!response.ok) {
|
|
1746
|
+
return this.handleErrorResponse(response, "batch");
|
|
1747
|
+
}
|
|
1748
|
+
const data = await response.json();
|
|
1749
|
+
return ok(data);
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
return err(wrapError("duckdb", error));
|
|
1752
|
+
}
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
async executeStatementOnDb(dbName, name, params, options) {
|
|
1756
|
+
return this.withTelemetry("executeStatement", dbName, async () => {
|
|
1757
|
+
if (!this.requireAuth()) {
|
|
1758
|
+
return err(authRequiredError("duckdb"));
|
|
1759
|
+
}
|
|
1760
|
+
try {
|
|
1761
|
+
const response = await this.invokeDuckDb(
|
|
1762
|
+
dbName,
|
|
1763
|
+
DuckDbAction.EXECUTE,
|
|
1764
|
+
{ action: "executeStatement", name, params: params ?? [] },
|
|
1765
|
+
options?.signal
|
|
1766
|
+
);
|
|
1767
|
+
if (!response.ok) {
|
|
1768
|
+
return this.handleErrorResponse(response, "executeStatement");
|
|
1769
|
+
}
|
|
1770
|
+
const data = await response.json();
|
|
1771
|
+
return ok(data);
|
|
1772
|
+
} catch (error) {
|
|
1773
|
+
return err(wrapError("duckdb", error));
|
|
1774
|
+
}
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
async describeDb(dbName, options) {
|
|
1778
|
+
return this.withTelemetry("describe", dbName, async () => {
|
|
1779
|
+
if (!this.requireAuth()) {
|
|
1780
|
+
return err(authRequiredError("duckdb"));
|
|
1781
|
+
}
|
|
1782
|
+
try {
|
|
1783
|
+
const response = await this.invokeDuckDb(
|
|
1784
|
+
dbName,
|
|
1785
|
+
DuckDbAction.DESCRIBE,
|
|
1786
|
+
{ action: "describe" },
|
|
1787
|
+
options?.signal
|
|
1788
|
+
);
|
|
1789
|
+
if (!response.ok) {
|
|
1790
|
+
return this.handleErrorResponse(response, "describe");
|
|
1791
|
+
}
|
|
1792
|
+
const data = await response.json();
|
|
1793
|
+
return ok(data);
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
return err(wrapError("duckdb", error));
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
async exportOnDb(dbName, options) {
|
|
1800
|
+
return this.withTelemetry("export", dbName, async () => {
|
|
1801
|
+
if (!this.requireAuth()) {
|
|
1802
|
+
return err(authRequiredError("duckdb"));
|
|
1803
|
+
}
|
|
1804
|
+
try {
|
|
1805
|
+
const response = await this.invokeDuckDb(
|
|
1806
|
+
dbName,
|
|
1807
|
+
DuckDbAction.EXPORT,
|
|
1808
|
+
{ action: "export" },
|
|
1809
|
+
options?.signal
|
|
1810
|
+
);
|
|
1811
|
+
if (!response.ok) {
|
|
1812
|
+
return this.handleErrorResponse(response, "export");
|
|
1813
|
+
}
|
|
1814
|
+
const blob = await response.blob();
|
|
1815
|
+
return ok(blob);
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
return err(wrapError("duckdb", error));
|
|
1818
|
+
}
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
async importOnDb(dbName, data, options) {
|
|
1822
|
+
return this.withTelemetry("import", dbName, async () => {
|
|
1823
|
+
if (!this.requireAuth()) {
|
|
1824
|
+
return err(authRequiredError("duckdb"));
|
|
1825
|
+
}
|
|
1826
|
+
try {
|
|
1827
|
+
const session = this.context.session;
|
|
1828
|
+
const headers = this.context.invoke(
|
|
1829
|
+
session,
|
|
1830
|
+
"duckdb",
|
|
1831
|
+
dbName,
|
|
1832
|
+
DuckDbAction.IMPORT
|
|
1833
|
+
);
|
|
1834
|
+
const response = await this.context.fetch(`${this.host}/invoke`, {
|
|
1835
|
+
method: "POST",
|
|
1836
|
+
headers: {
|
|
1837
|
+
...headers,
|
|
1838
|
+
"Content-Type": "application/x-duckdb"
|
|
1839
|
+
},
|
|
1840
|
+
body: new Blob([data]),
|
|
1841
|
+
signal: this.combineSignals(options?.signal)
|
|
1842
|
+
});
|
|
1843
|
+
if (!response.ok) {
|
|
1844
|
+
return this.handleErrorResponse(response, "import");
|
|
1845
|
+
}
|
|
1846
|
+
return ok(void 0);
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
return err(wrapError("duckdb", error));
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
// === Private helpers ===
|
|
1853
|
+
async invokeDuckDb(dbName, action, body, signal, extraHeaders) {
|
|
1854
|
+
const session = this.context.session;
|
|
1855
|
+
const headers = this.context.invoke(session, "duckdb", dbName, action);
|
|
1856
|
+
return this.context.fetch(`${this.host}/invoke`, {
|
|
1857
|
+
method: "POST",
|
|
1858
|
+
headers: {
|
|
1859
|
+
...headers,
|
|
1860
|
+
"Content-Type": "application/json",
|
|
1861
|
+
...extraHeaders
|
|
1862
|
+
},
|
|
1863
|
+
body: JSON.stringify(body),
|
|
1864
|
+
signal: this.combineSignals(signal)
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
async handleErrorResponse(response, operation) {
|
|
1868
|
+
const errorText = await response.text();
|
|
1869
|
+
let errorBody = {};
|
|
1870
|
+
try {
|
|
1871
|
+
errorBody = JSON.parse(errorText);
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
const errorCode = this.mapHttpStatusToErrorCode(
|
|
1875
|
+
response.status,
|
|
1876
|
+
errorBody.error
|
|
1877
|
+
);
|
|
1878
|
+
const message = errorBody.message || `DuckDB ${operation} failed: ${response.status} - ${errorText}`;
|
|
1879
|
+
const meta = { status: response.status, statusText: response.statusText };
|
|
1880
|
+
if (response.status === 401) {
|
|
1881
|
+
const { resource, action } = parseAuthError(errorText);
|
|
1882
|
+
if (action) meta.requiredAction = action;
|
|
1883
|
+
if (resource) meta.resource = resource;
|
|
1884
|
+
}
|
|
1885
|
+
return err(
|
|
1886
|
+
serviceError(errorCode, message, "duckdb", { meta })
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
mapHttpStatusToErrorCode(status, serverError) {
|
|
1890
|
+
switch (status) {
|
|
1891
|
+
case 400:
|
|
1892
|
+
return ErrorCodes.DUCKDB_ERROR;
|
|
1893
|
+
case 401:
|
|
1894
|
+
return ErrorCodes.AUTH_UNAUTHORIZED;
|
|
1895
|
+
case 403:
|
|
1896
|
+
if (serverError === "duckdb_readonly_violation") {
|
|
1897
|
+
return ErrorCodes.DUCKDB_READONLY_VIOLATION;
|
|
1898
|
+
}
|
|
1899
|
+
return ErrorCodes.DUCKDB_PERMISSION_DENIED;
|
|
1900
|
+
case 404:
|
|
1901
|
+
return ErrorCodes.DUCKDB_DATABASE_NOT_FOUND;
|
|
1902
|
+
case 413:
|
|
1903
|
+
return ErrorCodes.DUCKDB_RESPONSE_TOO_LARGE;
|
|
1904
|
+
case 429:
|
|
1905
|
+
return ErrorCodes.DUCKDB_QUOTA_EXCEEDED;
|
|
1906
|
+
default:
|
|
1907
|
+
return ErrorCodes.NETWORK_ERROR;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
DuckDbService.serviceName = "duckdb";
|
|
1912
|
+
|
|
1913
|
+
// src/quota/TinyCloudQuota.ts
|
|
1914
|
+
var TinyCloudQuota = class {
|
|
1915
|
+
constructor(config = {}) {
|
|
1916
|
+
this.quotaUrl = null;
|
|
1917
|
+
this.config = config;
|
|
1918
|
+
}
|
|
1919
|
+
/** Set the quota URL discovered from the /info endpoint */
|
|
1920
|
+
setQuotaUrl(url) {
|
|
1921
|
+
this.quotaUrl = url;
|
|
1922
|
+
}
|
|
1923
|
+
/** Whether a quota service is available */
|
|
1924
|
+
get available() {
|
|
1925
|
+
return this.quotaUrl !== null;
|
|
1926
|
+
}
|
|
1927
|
+
/** Query quota status for a space from the quota URL */
|
|
1928
|
+
async getQuota(spaceId) {
|
|
1929
|
+
if (!this.quotaUrl) return null;
|
|
1930
|
+
const resp = await fetch(
|
|
1931
|
+
`${this.quotaUrl}/api/quota/${encodeURIComponent(spaceId)}`
|
|
1932
|
+
);
|
|
1933
|
+
if (!resp.ok) return null;
|
|
1934
|
+
const data = await resp.json();
|
|
1935
|
+
return {
|
|
1936
|
+
limitBytes: data.storage_limit_bytes ?? 0
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
/** Trigger the upgrade callback when a quota error is encountered */
|
|
1940
|
+
handleQuotaError(info) {
|
|
1941
|
+
this.config.onUpgradeRequired?.(info);
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
// src/vault/types.ts
|
|
1946
|
+
var VaultPublicSpaceKVActions = [
|
|
1947
|
+
"tinycloud.kv/get",
|
|
1948
|
+
"tinycloud.kv/put",
|
|
1949
|
+
"tinycloud.kv/metadata"
|
|
1950
|
+
];
|
|
1951
|
+
var VaultVersionConfig = {
|
|
1952
|
+
"1": {
|
|
1953
|
+
masterMessage: (spaceId) => `tinycloud-vault-master-v1:${spaceId}`,
|
|
1954
|
+
identityMessage: "tinycloud-encryption-identity-v1"
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
var CURRENT_VAULT_VERSION = "1";
|
|
1958
|
+
var VaultHeaders = {
|
|
1959
|
+
VERSION: "x-vault-version",
|
|
1960
|
+
CIPHER: "x-vault-cipher",
|
|
1961
|
+
KEY_ID: "x-vault-key-id",
|
|
1962
|
+
CONTENT_TYPE: "x-vault-content-type",
|
|
1963
|
+
KDF: "x-vault-kdf",
|
|
1964
|
+
KEY_ROTATION: "x-vault-key-rotation",
|
|
1965
|
+
GRANT_VERSION: "x-vault-grant-version",
|
|
1966
|
+
GRANTOR: "x-vault-grantor"
|
|
1967
|
+
};
|
|
1968
|
+
|
|
1969
|
+
// src/vault/SignatureCache.ts
|
|
1970
|
+
var DB_NAME = "tinycloud-vault-cache";
|
|
1971
|
+
var DB_VERSION = 1;
|
|
1972
|
+
var STORE_NAME = "signatures";
|
|
1973
|
+
var WRAP_KEY_ID = "__wrap_key__";
|
|
1974
|
+
function isBrowser() {
|
|
1975
|
+
try {
|
|
1976
|
+
return typeof indexedDB !== "undefined" && typeof crypto !== "undefined" && typeof crypto.subtle !== "undefined";
|
|
1977
|
+
} catch {
|
|
1978
|
+
return false;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
function openDB() {
|
|
1982
|
+
return new Promise((resolve, reject) => {
|
|
1983
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
1984
|
+
request.onupgradeneeded = () => {
|
|
1985
|
+
const db = request.result;
|
|
1986
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
1987
|
+
db.createObjectStore(STORE_NAME);
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1990
|
+
request.onsuccess = () => resolve(request.result);
|
|
1991
|
+
request.onerror = () => reject(request.error);
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
function idbGet(db, key) {
|
|
1995
|
+
return new Promise((resolve, reject) => {
|
|
1996
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
1997
|
+
const store = tx.objectStore(STORE_NAME);
|
|
1998
|
+
const req = store.get(key);
|
|
1999
|
+
req.onsuccess = () => resolve(req.result);
|
|
2000
|
+
req.onerror = () => reject(req.error);
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
function idbPut(db, key, value) {
|
|
2004
|
+
return new Promise((resolve, reject) => {
|
|
2005
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
2006
|
+
const store = tx.objectStore(STORE_NAME);
|
|
2007
|
+
const req = store.put(value, key);
|
|
2008
|
+
req.onsuccess = () => resolve();
|
|
2009
|
+
req.onerror = () => reject(req.error);
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
function idbDelete(db, key) {
|
|
2013
|
+
return new Promise((resolve, reject) => {
|
|
2014
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
2015
|
+
const store = tx.objectStore(STORE_NAME);
|
|
2016
|
+
const req = store.delete(key);
|
|
2017
|
+
req.onsuccess = () => resolve();
|
|
2018
|
+
req.onerror = () => reject(req.error);
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
function idbKeys(db) {
|
|
2022
|
+
return new Promise((resolve, reject) => {
|
|
2023
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
2024
|
+
const store = tx.objectStore(STORE_NAME);
|
|
2025
|
+
const req = store.getAllKeys();
|
|
2026
|
+
req.onsuccess = () => resolve(req.result.filter((k) => typeof k === "string"));
|
|
2027
|
+
req.onerror = () => reject(req.error);
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
async function getWrapKey(db) {
|
|
2031
|
+
const existing = await idbGet(db, WRAP_KEY_ID);
|
|
2032
|
+
if (existing) return existing;
|
|
2033
|
+
const key = await crypto.subtle.generateKey(
|
|
2034
|
+
{ name: "AES-GCM", length: 256 },
|
|
2035
|
+
false,
|
|
2036
|
+
// non-extractable
|
|
2037
|
+
["encrypt", "decrypt"]
|
|
2038
|
+
);
|
|
2039
|
+
await idbPut(db, WRAP_KEY_ID, key);
|
|
2040
|
+
return key;
|
|
2041
|
+
}
|
|
2042
|
+
async function encryptSig(wrapKey, sigBytes) {
|
|
2043
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2044
|
+
const ciphertext = new Uint8Array(
|
|
2045
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, wrapKey, sigBytes)
|
|
2046
|
+
);
|
|
2047
|
+
return { iv, ciphertext };
|
|
2048
|
+
}
|
|
2049
|
+
async function decryptSig(wrapKey, entry) {
|
|
2050
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
2051
|
+
{ name: "AES-GCM", iv: entry.iv },
|
|
2052
|
+
wrapKey,
|
|
2053
|
+
entry.ciphertext
|
|
2054
|
+
);
|
|
2055
|
+
return new Uint8Array(plaintext);
|
|
2056
|
+
}
|
|
2057
|
+
function cacheKey(spaceId) {
|
|
2058
|
+
return `sig:${spaceId}`;
|
|
2059
|
+
}
|
|
2060
|
+
async function loadCachedSignature(spaceId) {
|
|
2061
|
+
if (!isBrowser()) return null;
|
|
2062
|
+
try {
|
|
2063
|
+
const db = await openDB();
|
|
2064
|
+
const entry = await idbGet(db, cacheKey(spaceId));
|
|
2065
|
+
if (!entry) return null;
|
|
2066
|
+
const wrapKey = await getWrapKey(db);
|
|
2067
|
+
return await decryptSig(wrapKey, entry);
|
|
2068
|
+
} catch {
|
|
2069
|
+
return null;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
async function cacheSignature(spaceId, sigBytes) {
|
|
2073
|
+
if (!isBrowser()) return;
|
|
2074
|
+
try {
|
|
2075
|
+
const db = await openDB();
|
|
2076
|
+
const wrapKey = await getWrapKey(db);
|
|
2077
|
+
const encrypted = await encryptSig(wrapKey, sigBytes);
|
|
2078
|
+
await idbPut(db, cacheKey(spaceId), encrypted);
|
|
2079
|
+
} catch {
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
async function clearSignatureCache(spaceId) {
|
|
2083
|
+
if (!isBrowser()) return;
|
|
2084
|
+
try {
|
|
2085
|
+
const db = await openDB();
|
|
2086
|
+
if (spaceId) {
|
|
2087
|
+
await idbDelete(db, cacheKey(spaceId));
|
|
2088
|
+
} else {
|
|
2089
|
+
const keys = await idbKeys(db);
|
|
2090
|
+
for (const k of keys) {
|
|
2091
|
+
if (k.startsWith("sig:")) {
|
|
2092
|
+
await idbDelete(db, k);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
} catch {
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// src/vault/DataVaultService.ts
|
|
2101
|
+
function toError(error) {
|
|
2102
|
+
if (error instanceof Error) return error;
|
|
2103
|
+
if (typeof error === "object" && error !== null) {
|
|
2104
|
+
return new Error(JSON.stringify(error));
|
|
2105
|
+
}
|
|
2106
|
+
return new Error(String(error));
|
|
2107
|
+
}
|
|
2108
|
+
function toBytes(str) {
|
|
2109
|
+
return new TextEncoder().encode(str);
|
|
2110
|
+
}
|
|
2111
|
+
function fromBytes(bytes) {
|
|
2112
|
+
return new TextDecoder().decode(bytes);
|
|
2113
|
+
}
|
|
2114
|
+
function hexEncode(bytes) {
|
|
2115
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2116
|
+
}
|
|
2117
|
+
function concatBytes(...arrays) {
|
|
2118
|
+
const total = arrays.reduce((acc, arr) => acc + arr.length, 0);
|
|
2119
|
+
const result = new Uint8Array(total);
|
|
2120
|
+
let offset = 0;
|
|
2121
|
+
for (const arr of arrays) {
|
|
2122
|
+
result.set(arr, offset);
|
|
2123
|
+
offset += arr.length;
|
|
2124
|
+
}
|
|
2125
|
+
return result;
|
|
2126
|
+
}
|
|
2127
|
+
function base64Encode(bytes) {
|
|
2128
|
+
let binary = "";
|
|
2129
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
2130
|
+
binary += String.fromCharCode(bytes[i]);
|
|
2131
|
+
}
|
|
2132
|
+
return btoa(binary);
|
|
2133
|
+
}
|
|
2134
|
+
function base64Decode(str) {
|
|
2135
|
+
const binary = atob(str);
|
|
2136
|
+
const bytes = new Uint8Array(binary.length);
|
|
2137
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2138
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2139
|
+
}
|
|
2140
|
+
return bytes;
|
|
2141
|
+
}
|
|
2142
|
+
function defaultVaultMessage(input) {
|
|
2143
|
+
switch (input.code) {
|
|
2144
|
+
case "DECRYPTION_FAILED":
|
|
2145
|
+
return input.message ?? "Decryption failed";
|
|
2146
|
+
case "KEY_NOT_FOUND":
|
|
2147
|
+
return input.message ?? `Key not found: ${input.key}`;
|
|
2148
|
+
case "INTEGRITY_ERROR":
|
|
2149
|
+
return input.message ?? "Integrity check failed";
|
|
2150
|
+
case "GRANT_NOT_FOUND":
|
|
2151
|
+
return input.message ?? `Grant not found: ${input.grantor} / ${input.key}`;
|
|
2152
|
+
case "VAULT_LOCKED":
|
|
2153
|
+
return input.message ?? "Vault is locked";
|
|
2154
|
+
case "PUBLIC_KEY_NOT_FOUND":
|
|
2155
|
+
return input.message ?? `Public key not found for ${input.did}`;
|
|
2156
|
+
case "STORAGE_ERROR":
|
|
2157
|
+
return input.message ?? input.cause.message;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
function vaultError(input) {
|
|
2161
|
+
const error = {
|
|
2162
|
+
...input,
|
|
2163
|
+
service: "vault",
|
|
2164
|
+
message: defaultVaultMessage(input)
|
|
2165
|
+
};
|
|
2166
|
+
return { ok: false, error };
|
|
2167
|
+
}
|
|
2168
|
+
var DataVaultService = class extends BaseService {
|
|
2169
|
+
/**
|
|
2170
|
+
* Create a new DataVaultService instance.
|
|
2171
|
+
*
|
|
2172
|
+
* @param config - Service configuration including crypto and tc references
|
|
2173
|
+
*/
|
|
2174
|
+
constructor(config) {
|
|
2175
|
+
super();
|
|
2176
|
+
this.masterKey = null;
|
|
2177
|
+
this.encryptionIdentity = null;
|
|
2178
|
+
this._isUnlocked = false;
|
|
2179
|
+
this.vaultConfig = config;
|
|
2180
|
+
this._config = config;
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Get the service configuration.
|
|
2184
|
+
*/
|
|
2185
|
+
get config() {
|
|
2186
|
+
return this._config;
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Whether the vault is currently unlocked.
|
|
2190
|
+
*/
|
|
2191
|
+
get isUnlocked() {
|
|
2192
|
+
return this._isUnlocked;
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* The vault's public encryption key (X25519).
|
|
2196
|
+
* Throws if vault is locked.
|
|
2197
|
+
*/
|
|
2198
|
+
get publicKey() {
|
|
2199
|
+
if (!this.encryptionIdentity) {
|
|
2200
|
+
throw new Error("Vault is locked");
|
|
2201
|
+
}
|
|
2202
|
+
return this.encryptionIdentity.publicKey;
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Convenience accessor for crypto operations.
|
|
2206
|
+
*/
|
|
2207
|
+
get crypto() {
|
|
2208
|
+
return this.vaultConfig.crypto;
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Convenience accessor for TinyCloud instance.
|
|
2212
|
+
*/
|
|
2213
|
+
get tc() {
|
|
2214
|
+
return this.vaultConfig.tc;
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Get the host URL.
|
|
2218
|
+
*/
|
|
2219
|
+
get host() {
|
|
2220
|
+
return this.tc.hosts[0];
|
|
2221
|
+
}
|
|
2222
|
+
// =========================================================================
|
|
2223
|
+
// Phase 1: Core Operations
|
|
2224
|
+
// =========================================================================
|
|
2225
|
+
/**
|
|
2226
|
+
* Unlock the vault. Derives keys from two wallet signatures:
|
|
2227
|
+
* 1. Master signature (per-space) — used to derive the master encryption key
|
|
2228
|
+
* 2. Identity signature (per-address) — used to derive X25519 encryption identity
|
|
2229
|
+
*
|
|
2230
|
+
* If the identity public key already exists in the public space, the identity
|
|
2231
|
+
* signature is skipped entirely (no wallet popup). The identity private key is
|
|
2232
|
+
* only needed for sharing operations.
|
|
2233
|
+
*
|
|
2234
|
+
* @param signer - Object with signMessage method. Optional when cached
|
|
2235
|
+
* signatures exist (browser only).
|
|
2236
|
+
*/
|
|
2237
|
+
async unlock(signer) {
|
|
2238
|
+
return this.withTelemetry("unlock", void 0, async () => {
|
|
2239
|
+
const spaceId = this.vaultConfig.spaceId;
|
|
2240
|
+
const versionConfig = VaultVersionConfig[CURRENT_VAULT_VERSION];
|
|
2241
|
+
const masterCacheKey = `vault-master:${spaceId}`;
|
|
2242
|
+
const identityCacheKey = `vault-identity:${this.tc.address}`;
|
|
2243
|
+
try {
|
|
2244
|
+
let masterSigBytes = await loadCachedSignature(masterCacheKey);
|
|
2245
|
+
if (!masterSigBytes) {
|
|
2246
|
+
if (!signer) {
|
|
2247
|
+
return vaultError({
|
|
2248
|
+
code: "VAULT_LOCKED",
|
|
2249
|
+
message: "Signer is required when no cached master signature exists"
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
const s = signer;
|
|
2253
|
+
const sig = await s.signMessage(versionConfig.masterMessage(spaceId));
|
|
2254
|
+
masterSigBytes = toBytes(sig);
|
|
2255
|
+
await cacheSignature(masterCacheKey, masterSigBytes);
|
|
2256
|
+
}
|
|
2257
|
+
this.masterKey = this.crypto.deriveKey(
|
|
2258
|
+
masterSigBytes,
|
|
2259
|
+
this.crypto.sha256(toBytes(spaceId)),
|
|
2260
|
+
toBytes("vault-master")
|
|
2261
|
+
);
|
|
2262
|
+
const publicSpaceId = this.tc.makePublicSpaceId(this.tc.address, this.tc.chainId);
|
|
2263
|
+
let existingPubKey = null;
|
|
2264
|
+
try {
|
|
2265
|
+
const existing = await this.tc.readPublicSpace(
|
|
2266
|
+
this.host,
|
|
2267
|
+
publicSpaceId,
|
|
2268
|
+
".well-known/vault-pubkey"
|
|
2269
|
+
);
|
|
2270
|
+
if (existing.ok && existing.data) {
|
|
2271
|
+
existingPubKey = existing.data;
|
|
2272
|
+
}
|
|
2273
|
+
} catch {
|
|
2274
|
+
}
|
|
2275
|
+
if (existingPubKey) {
|
|
2276
|
+
this.encryptionIdentity = {
|
|
2277
|
+
publicKey: base64Decode(existingPubKey),
|
|
2278
|
+
privateKey: new Uint8Array(0)
|
|
2279
|
+
// private key not available without signing
|
|
2280
|
+
};
|
|
2281
|
+
} else {
|
|
2282
|
+
let identitySigBytes = await loadCachedSignature(identityCacheKey);
|
|
2283
|
+
if (!identitySigBytes) {
|
|
2284
|
+
if (!signer) {
|
|
2285
|
+
this.encryptionIdentity = null;
|
|
2286
|
+
this._isUnlocked = true;
|
|
2287
|
+
return ok(void 0);
|
|
2288
|
+
}
|
|
2289
|
+
const s = signer;
|
|
2290
|
+
const sig = await s.signMessage(versionConfig.identityMessage);
|
|
2291
|
+
identitySigBytes = toBytes(sig);
|
|
2292
|
+
await cacheSignature(identityCacheKey, identitySigBytes);
|
|
2293
|
+
}
|
|
2294
|
+
const seed = this.crypto.deriveKey(
|
|
2295
|
+
identitySigBytes,
|
|
2296
|
+
toBytes("tinycloud-x25519"),
|
|
2297
|
+
toBytes("encryption-identity")
|
|
2298
|
+
);
|
|
2299
|
+
this.encryptionIdentity = this.crypto.x25519FromSeed(seed);
|
|
2300
|
+
try {
|
|
2301
|
+
const pubKeyB64 = base64Encode(this.encryptionIdentity.publicKey);
|
|
2302
|
+
await this.tc.ensurePublicSpace();
|
|
2303
|
+
await this.tc.publicKV.put(".well-known/vault-pubkey", pubKeyB64);
|
|
2304
|
+
await this.tc.publicKV.put(".well-known/vault-version", CURRENT_VAULT_VERSION);
|
|
2305
|
+
await this.tc.publicKV.put(".well-known/vault-space", this.vaultConfig.spaceId);
|
|
2306
|
+
} catch {
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
this._isUnlocked = true;
|
|
2310
|
+
return ok(void 0);
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
this.masterKey = null;
|
|
2313
|
+
this.encryptionIdentity = null;
|
|
2314
|
+
return vaultError({
|
|
2315
|
+
code: "STORAGE_ERROR",
|
|
2316
|
+
cause: toError(error)
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Clear the cached vault signatures.
|
|
2323
|
+
*
|
|
2324
|
+
* @param spaceId - Clear only this space's master cache. If omitted, clears all.
|
|
2325
|
+
*/
|
|
2326
|
+
async clearCache(spaceId) {
|
|
2327
|
+
if (spaceId) {
|
|
2328
|
+
await clearSignatureCache(`vault-master:${spaceId}`);
|
|
2329
|
+
} else {
|
|
2330
|
+
await clearSignatureCache();
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Lock the vault, clearing all key material from memory.
|
|
2335
|
+
*/
|
|
2336
|
+
lock() {
|
|
2337
|
+
this.masterKey = null;
|
|
2338
|
+
this.encryptionIdentity = null;
|
|
2339
|
+
this._isUnlocked = false;
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* Called when SDK signs out. Locks the vault and aborts operations.
|
|
2343
|
+
*/
|
|
2344
|
+
onSignOut() {
|
|
2345
|
+
this.lock();
|
|
2346
|
+
super.onSignOut();
|
|
2347
|
+
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Encrypt and store a value at the given key.
|
|
2350
|
+
*
|
|
2351
|
+
* @param key - The key to store under
|
|
2352
|
+
* @param value - The value to encrypt and store
|
|
2353
|
+
* @param options - Optional put configuration
|
|
2354
|
+
*/
|
|
2355
|
+
async put(key, value, options) {
|
|
2356
|
+
return this.withTelemetry("put", key, async () => {
|
|
2357
|
+
if (!this._isUnlocked || !this.masterKey) {
|
|
2358
|
+
return vaultError({
|
|
2359
|
+
code: "VAULT_LOCKED",
|
|
2360
|
+
message: "Vault must be unlocked before storing data"
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
if (!this.requireAuth()) {
|
|
2364
|
+
return vaultError({
|
|
2365
|
+
code: "VAULT_LOCKED",
|
|
2366
|
+
message: "Authentication required"
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
try {
|
|
2370
|
+
let plaintext;
|
|
2371
|
+
if (value instanceof Uint8Array) {
|
|
2372
|
+
plaintext = value;
|
|
2373
|
+
} else if (options?.serialize) {
|
|
2374
|
+
plaintext = options.serialize(value);
|
|
2375
|
+
} else if (typeof value === "string") {
|
|
2376
|
+
plaintext = toBytes(value);
|
|
2377
|
+
} else {
|
|
2378
|
+
plaintext = toBytes(JSON.stringify(value));
|
|
2379
|
+
}
|
|
2380
|
+
const contentType = options?.contentType ?? (value instanceof Uint8Array ? "application/octet-stream" : "application/json");
|
|
2381
|
+
const entryKey = this.crypto.randomBytes(32);
|
|
2382
|
+
const keyId = hexEncode(this.crypto.sha256(entryKey)).slice(0, 16);
|
|
2383
|
+
const encrypted = this.crypto.encrypt(entryKey, plaintext);
|
|
2384
|
+
const keyBlob = this.crypto.encrypt(this.masterKey, entryKey);
|
|
2385
|
+
const metadata = {
|
|
2386
|
+
[VaultHeaders.VERSION]: "1",
|
|
2387
|
+
[VaultHeaders.CIPHER]: "aes-256-gcm",
|
|
2388
|
+
[VaultHeaders.KEY_ID]: keyId,
|
|
2389
|
+
[VaultHeaders.CONTENT_TYPE]: contentType,
|
|
2390
|
+
[VaultHeaders.KDF]: "hkdf-sha256",
|
|
2391
|
+
[VaultHeaders.KEY_ROTATION]: this.vaultConfig.keyRotation ?? "per-write",
|
|
2392
|
+
...options?.metadata ?? {}
|
|
2393
|
+
};
|
|
2394
|
+
const keyMetadata = JSON.stringify({
|
|
2395
|
+
keyId,
|
|
2396
|
+
contentType,
|
|
2397
|
+
...metadata
|
|
2398
|
+
});
|
|
2399
|
+
const keyPayload = JSON.stringify({
|
|
2400
|
+
key: base64Encode(keyBlob),
|
|
2401
|
+
metadata: keyMetadata
|
|
2402
|
+
});
|
|
2403
|
+
const keyPutResult = await this.tc.kv.put(
|
|
2404
|
+
`keys/${key}`,
|
|
2405
|
+
keyPayload
|
|
2406
|
+
);
|
|
2407
|
+
if (!keyPutResult.ok) {
|
|
2408
|
+
return vaultError({
|
|
2409
|
+
code: "STORAGE_ERROR",
|
|
2410
|
+
cause: new Error(
|
|
2411
|
+
`Failed to store key blob: ${keyPutResult.error.message}`
|
|
2412
|
+
)
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
const valuePayload = JSON.stringify({
|
|
2416
|
+
data: base64Encode(encrypted),
|
|
2417
|
+
metadata
|
|
2418
|
+
});
|
|
2419
|
+
const valuePutResult = await this.tc.kv.put(
|
|
2420
|
+
`vault/${key}`,
|
|
2421
|
+
valuePayload
|
|
2422
|
+
);
|
|
2423
|
+
if (!valuePutResult.ok) {
|
|
2424
|
+
return vaultError({
|
|
2425
|
+
code: "STORAGE_ERROR",
|
|
2426
|
+
cause: new Error(
|
|
2427
|
+
`Failed to store encrypted value: ${valuePutResult.error.message}`
|
|
2428
|
+
)
|
|
2429
|
+
});
|
|
2430
|
+
}
|
|
2431
|
+
return ok(void 0);
|
|
2432
|
+
} catch (error) {
|
|
2433
|
+
return vaultError({
|
|
2434
|
+
code: "STORAGE_ERROR",
|
|
2435
|
+
cause: toError(error)
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Retrieve and decrypt a value by key.
|
|
2442
|
+
*
|
|
2443
|
+
* @param key - The key to retrieve
|
|
2444
|
+
* @param options - Optional get configuration
|
|
2445
|
+
* @returns Result with the decrypted entry
|
|
2446
|
+
*/
|
|
2447
|
+
async get(key, options) {
|
|
2448
|
+
return this.withTelemetry("get", key, async () => {
|
|
2449
|
+
if (!this._isUnlocked || !this.masterKey) {
|
|
2450
|
+
return vaultError({
|
|
2451
|
+
code: "VAULT_LOCKED",
|
|
2452
|
+
message: "Vault must be unlocked before reading data"
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
if (!this.requireAuth()) {
|
|
2456
|
+
return vaultError({
|
|
2457
|
+
code: "VAULT_LOCKED",
|
|
2458
|
+
message: "Authentication required"
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
try {
|
|
2462
|
+
const keyResult = await this.tc.kv.get(`keys/${key}`, {
|
|
2463
|
+
raw: true
|
|
2464
|
+
});
|
|
2465
|
+
if (!keyResult.ok) {
|
|
2466
|
+
return vaultError({ code: "KEY_NOT_FOUND", key });
|
|
2467
|
+
}
|
|
2468
|
+
const keyEnvelope = JSON.parse(keyResult.data.data);
|
|
2469
|
+
const keyBlobBytes = base64Decode(keyEnvelope.key);
|
|
2470
|
+
const entryKey = this.crypto.decrypt(this.masterKey, keyBlobBytes);
|
|
2471
|
+
const valueResult = await this.tc.kv.get(`vault/${key}`, {
|
|
2472
|
+
raw: true
|
|
2473
|
+
});
|
|
2474
|
+
if (!valueResult.ok) {
|
|
2475
|
+
return vaultError({ code: "KEY_NOT_FOUND", key });
|
|
2476
|
+
}
|
|
2477
|
+
const valueEnvelope = JSON.parse(valueResult.data.data);
|
|
2478
|
+
const encryptedBytes = base64Decode(valueEnvelope.data);
|
|
2479
|
+
const plaintext = this.crypto.decrypt(entryKey, encryptedBytes);
|
|
2480
|
+
const metadata = valueEnvelope.metadata ?? {};
|
|
2481
|
+
const contentType = metadata[VaultHeaders.CONTENT_TYPE] ?? "application/json";
|
|
2482
|
+
const keyId = metadata[VaultHeaders.KEY_ID] ?? "";
|
|
2483
|
+
let value;
|
|
2484
|
+
if (options?.raw) {
|
|
2485
|
+
value = plaintext;
|
|
2486
|
+
} else if (options?.deserialize) {
|
|
2487
|
+
value = options.deserialize(plaintext);
|
|
2488
|
+
} else if (contentType === "application/json") {
|
|
2489
|
+
value = JSON.parse(fromBytes(plaintext));
|
|
2490
|
+
} else {
|
|
2491
|
+
value = plaintext;
|
|
2492
|
+
}
|
|
2493
|
+
return ok({ value, metadata, keyId });
|
|
2494
|
+
} catch (error) {
|
|
2495
|
+
if (error instanceof Error && error.message.includes("decryption")) {
|
|
2496
|
+
return vaultError({
|
|
2497
|
+
code: "DECRYPTION_FAILED",
|
|
2498
|
+
message: error.message
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
return vaultError({
|
|
2502
|
+
code: "STORAGE_ERROR",
|
|
2503
|
+
cause: toError(error)
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* Delete an encrypted key.
|
|
2510
|
+
* Removes both the encrypted value and the key blob.
|
|
2511
|
+
*
|
|
2512
|
+
* @param key - The key to delete
|
|
2513
|
+
*/
|
|
2514
|
+
async delete(key) {
|
|
2515
|
+
return this.withTelemetry("delete", key, async () => {
|
|
2516
|
+
if (!this._isUnlocked) {
|
|
2517
|
+
return vaultError({
|
|
2518
|
+
code: "VAULT_LOCKED",
|
|
2519
|
+
message: "Vault must be unlocked before deleting data"
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
if (!this.requireAuth()) {
|
|
2523
|
+
return vaultError({
|
|
2524
|
+
code: "VAULT_LOCKED",
|
|
2525
|
+
message: "Authentication required"
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2528
|
+
try {
|
|
2529
|
+
const [keyDelResult, valueDelResult] = await Promise.all([
|
|
2530
|
+
this.tc.kv.delete(`keys/${key}`),
|
|
2531
|
+
this.tc.kv.delete(`vault/${key}`)
|
|
2532
|
+
]);
|
|
2533
|
+
if (!keyDelResult.ok && !valueDelResult.ok) {
|
|
2534
|
+
return vaultError({ code: "KEY_NOT_FOUND", key });
|
|
2535
|
+
}
|
|
2536
|
+
return ok(void 0);
|
|
2537
|
+
} catch (error) {
|
|
2538
|
+
return vaultError({
|
|
2539
|
+
code: "STORAGE_ERROR",
|
|
2540
|
+
cause: toError(error)
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
});
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
2546
|
+
* List vault keys with optional prefix filtering.
|
|
2547
|
+
*
|
|
2548
|
+
* @param options - Optional list configuration
|
|
2549
|
+
* @returns Result with array of key names (vault/ prefix stripped)
|
|
2550
|
+
*/
|
|
2551
|
+
async list(options) {
|
|
2552
|
+
return this.withTelemetry("list", options?.prefix, async () => {
|
|
2553
|
+
if (!this._isUnlocked) {
|
|
2554
|
+
return vaultError({
|
|
2555
|
+
code: "VAULT_LOCKED",
|
|
2556
|
+
message: "Vault must be unlocked before listing data"
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
if (!this.requireAuth()) {
|
|
2560
|
+
return vaultError({
|
|
2561
|
+
code: "VAULT_LOCKED",
|
|
2562
|
+
message: "Authentication required"
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
try {
|
|
2566
|
+
const listPrefix = options?.prefix ? `vault/${options.prefix}` : "vault/";
|
|
2567
|
+
const listResult = await this.tc.kv.list({
|
|
2568
|
+
prefix: listPrefix,
|
|
2569
|
+
removePrefix: true
|
|
2570
|
+
});
|
|
2571
|
+
if (!listResult.ok) {
|
|
2572
|
+
return vaultError({
|
|
2573
|
+
code: "STORAGE_ERROR",
|
|
2574
|
+
cause: new Error(
|
|
2575
|
+
`Failed to list vault keys: ${listResult.error.message}`
|
|
2576
|
+
)
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
let keys = listResult.data.keys;
|
|
2580
|
+
if (options?.removePrefix && options.prefix) {
|
|
2581
|
+
const userPrefix = options.prefix.endsWith("/") ? options.prefix : `${options.prefix}/`;
|
|
2582
|
+
keys = keys.map(
|
|
2583
|
+
(k) => k.startsWith(userPrefix) ? k.slice(userPrefix.length) : k
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
return ok(keys);
|
|
2587
|
+
} catch (error) {
|
|
2588
|
+
return vaultError({
|
|
2589
|
+
code: "STORAGE_ERROR",
|
|
2590
|
+
cause: toError(error)
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Get envelope metadata for a key without decrypting the value.
|
|
2597
|
+
*
|
|
2598
|
+
* @param key - The key to inspect
|
|
2599
|
+
* @returns Result with metadata headers
|
|
2600
|
+
*/
|
|
2601
|
+
async head(key) {
|
|
2602
|
+
return this.withTelemetry("head", key, async () => {
|
|
2603
|
+
if (!this._isUnlocked) {
|
|
2604
|
+
return vaultError({
|
|
2605
|
+
code: "VAULT_LOCKED",
|
|
2606
|
+
message: "Vault must be unlocked before reading metadata"
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
if (!this.requireAuth()) {
|
|
2610
|
+
return vaultError({
|
|
2611
|
+
code: "VAULT_LOCKED",
|
|
2612
|
+
message: "Authentication required"
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
try {
|
|
2616
|
+
const valueResult = await this.tc.kv.get(`vault/${key}`, {
|
|
2617
|
+
raw: true
|
|
2618
|
+
});
|
|
2619
|
+
if (!valueResult.ok) {
|
|
2620
|
+
return vaultError({ code: "KEY_NOT_FOUND", key });
|
|
2621
|
+
}
|
|
2622
|
+
const valueEnvelope = JSON.parse(valueResult.data.data);
|
|
2623
|
+
const metadata = valueEnvelope.metadata ?? {};
|
|
2624
|
+
return ok(metadata);
|
|
2625
|
+
} catch (error) {
|
|
2626
|
+
return vaultError({
|
|
2627
|
+
code: "STORAGE_ERROR",
|
|
2628
|
+
cause: toError(error)
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
// =========================================================================
|
|
2634
|
+
// Batch Operations
|
|
2635
|
+
// =========================================================================
|
|
2636
|
+
/**
|
|
2637
|
+
* Encrypt and store multiple entries.
|
|
2638
|
+
*
|
|
2639
|
+
* @param entries - Array of key/value pairs with optional per-entry options
|
|
2640
|
+
* @returns Array of results, one per entry
|
|
2641
|
+
*/
|
|
2642
|
+
async putMany(entries) {
|
|
2643
|
+
return Promise.all(
|
|
2644
|
+
entries.map((entry) => this.put(entry.key, entry.value, entry.options))
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* Retrieve and decrypt multiple keys.
|
|
2649
|
+
*
|
|
2650
|
+
* @param keys - Array of keys to retrieve
|
|
2651
|
+
* @param options - Optional get configuration applied to all entries
|
|
2652
|
+
* @returns Array of results, one per key
|
|
2653
|
+
*/
|
|
2654
|
+
async getMany(keys, options) {
|
|
2655
|
+
return Promise.all(keys.map((key) => this.get(key, options)));
|
|
2656
|
+
}
|
|
2657
|
+
// =========================================================================
|
|
2658
|
+
// Phase 2: Sharing
|
|
2659
|
+
// =========================================================================
|
|
2660
|
+
/**
|
|
2661
|
+
* Re-encrypt a vault key for another user (renamed from grant).
|
|
2662
|
+
* Re-encrypts the data key to the recipient's public key via X25519 DH.
|
|
2663
|
+
*
|
|
2664
|
+
* @param key - The key to share
|
|
2665
|
+
* @param recipientDID - The recipient's primary DID (did:pkh:...)
|
|
2666
|
+
* @param options - Optional grant configuration
|
|
2667
|
+
*/
|
|
2668
|
+
async reencrypt(key, recipientDID, options) {
|
|
2669
|
+
return this.withTelemetry("reencrypt", key, async () => {
|
|
2670
|
+
if (!this._isUnlocked || !this.masterKey) {
|
|
2671
|
+
return vaultError({
|
|
2672
|
+
code: "VAULT_LOCKED",
|
|
2673
|
+
message: "Vault must be unlocked before granting access"
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
if (!this.requireAuth()) {
|
|
2677
|
+
return vaultError({
|
|
2678
|
+
code: "VAULT_LOCKED",
|
|
2679
|
+
message: "Authentication required"
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2682
|
+
try {
|
|
2683
|
+
const pubKeyResult = await this.resolvePublicKey(recipientDID);
|
|
2684
|
+
if (!pubKeyResult.ok) {
|
|
2685
|
+
return pubKeyResult;
|
|
2686
|
+
}
|
|
2687
|
+
const bobPubKey = pubKeyResult.data;
|
|
2688
|
+
const keyResult = await this.tc.kv.get(`keys/${key}`, {
|
|
2689
|
+
raw: true
|
|
2690
|
+
});
|
|
2691
|
+
if (!keyResult.ok) {
|
|
2692
|
+
return vaultError({ code: "KEY_NOT_FOUND", key });
|
|
2693
|
+
}
|
|
2694
|
+
const keyEnvelope = JSON.parse(keyResult.data.data);
|
|
2695
|
+
const keyBlobBytes = base64Decode(keyEnvelope.key);
|
|
2696
|
+
const entryKey = this.crypto.decrypt(this.masterKey, keyBlobBytes);
|
|
2697
|
+
const ephemeralSeed = this.crypto.randomBytes(32);
|
|
2698
|
+
const ephemeralKeyPair = this.crypto.x25519FromSeed(ephemeralSeed);
|
|
2699
|
+
const sharedSecret = this.crypto.x25519Dh(
|
|
2700
|
+
ephemeralKeyPair.privateKey,
|
|
2701
|
+
bobPubKey
|
|
2702
|
+
);
|
|
2703
|
+
const encryptionKey = this.crypto.deriveKey(
|
|
2704
|
+
sharedSecret,
|
|
2705
|
+
toBytes("tinycloud-x25519"),
|
|
2706
|
+
toBytes("vault-grant")
|
|
2707
|
+
);
|
|
2708
|
+
const encryptedGrant = this.crypto.encrypt(encryptionKey, entryKey);
|
|
2709
|
+
const grantBlob = concatBytes(
|
|
2710
|
+
ephemeralKeyPair.publicKey,
|
|
2711
|
+
encryptedGrant
|
|
2712
|
+
);
|
|
2713
|
+
const grantPayload = JSON.stringify({
|
|
2714
|
+
grant: base64Encode(grantBlob),
|
|
2715
|
+
spaceId: this.vaultConfig.spaceId,
|
|
2716
|
+
metadata: {
|
|
2717
|
+
[VaultHeaders.GRANT_VERSION]: "1",
|
|
2718
|
+
[VaultHeaders.GRANTOR]: this.tc.did,
|
|
2719
|
+
...options?.metadata ?? {}
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
const grantPutResult = await this.tc.kv.put(
|
|
2723
|
+
`grants/${recipientDID}/${key}`,
|
|
2724
|
+
grantPayload
|
|
2725
|
+
);
|
|
2726
|
+
if (!grantPutResult.ok) {
|
|
2727
|
+
return vaultError({
|
|
2728
|
+
code: "STORAGE_ERROR",
|
|
2729
|
+
cause: new Error(
|
|
2730
|
+
`Failed to store grant: ${grantPutResult.error.message}`
|
|
2731
|
+
)
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
return ok(void 0);
|
|
2735
|
+
} catch (error) {
|
|
2736
|
+
return vaultError({
|
|
2737
|
+
code: "STORAGE_ERROR",
|
|
2738
|
+
cause: toError(error)
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* @deprecated Use reencrypt() instead.
|
|
2745
|
+
*/
|
|
2746
|
+
async grant(key, recipientDID, options) {
|
|
2747
|
+
return this.reencrypt(key, recipientDID, options);
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* Retrieve and decrypt a value shared by another user.
|
|
2751
|
+
*
|
|
2752
|
+
* @param grantorDID - The DID of the user who shared the data
|
|
2753
|
+
* @param key - The key that was shared
|
|
2754
|
+
* @param options - Optional get configuration
|
|
2755
|
+
* @returns Result with the decrypted entry
|
|
2756
|
+
*/
|
|
2757
|
+
async getShared(grantorDID, key, options) {
|
|
2758
|
+
return this.withTelemetry("getShared", key, async () => {
|
|
2759
|
+
if (!this._isUnlocked || !this.masterKey || !this.encryptionIdentity) {
|
|
2760
|
+
return vaultError({
|
|
2761
|
+
code: "VAULT_LOCKED",
|
|
2762
|
+
message: "Vault must be unlocked before reading shared data"
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
if (!this.requireAuth()) {
|
|
2766
|
+
return vaultError({
|
|
2767
|
+
code: "VAULT_LOCKED",
|
|
2768
|
+
message: "Authentication required"
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
try {
|
|
2772
|
+
const myDID = this.tc.did;
|
|
2773
|
+
const grantorKV = options?.kv;
|
|
2774
|
+
if (!grantorKV) {
|
|
2775
|
+
return vaultError({
|
|
2776
|
+
code: "STORAGE_ERROR",
|
|
2777
|
+
cause: new Error(
|
|
2778
|
+
"getShared requires a delegated KV service via options.kv. Use useDelegation() to get delegated access, then pass { kv: access.kv }."
|
|
2779
|
+
)
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
const grantResult = await grantorKV.get(`grants/${myDID}/${key}`, {
|
|
2783
|
+
raw: true
|
|
2784
|
+
});
|
|
2785
|
+
if (!grantResult.ok) {
|
|
2786
|
+
return vaultError({
|
|
2787
|
+
code: "GRANT_NOT_FOUND",
|
|
2788
|
+
grantor: grantorDID,
|
|
2789
|
+
key
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
const grantEnvelope = typeof grantResult.data?.data === "string" ? JSON.parse(grantResult.data.data) : grantResult.data?.data;
|
|
2793
|
+
const grantBlobBytes = base64Decode(grantEnvelope.grant);
|
|
2794
|
+
const ephemeralPubKey = grantBlobBytes.slice(0, 32);
|
|
2795
|
+
const encryptedGrant = grantBlobBytes.slice(32);
|
|
2796
|
+
const sharedSecret = this.crypto.x25519Dh(
|
|
2797
|
+
this.encryptionIdentity.privateKey,
|
|
2798
|
+
ephemeralPubKey
|
|
2799
|
+
);
|
|
2800
|
+
const encryptionKey = this.crypto.deriveKey(
|
|
2801
|
+
sharedSecret,
|
|
2802
|
+
toBytes("tinycloud-x25519"),
|
|
2803
|
+
toBytes("vault-grant")
|
|
2804
|
+
);
|
|
2805
|
+
const entryKey = this.crypto.decrypt(encryptionKey, encryptedGrant);
|
|
2806
|
+
const valueResult = await grantorKV.get(`vault/${key}`, {
|
|
2807
|
+
raw: true
|
|
2808
|
+
});
|
|
2809
|
+
if (!valueResult.ok) {
|
|
2810
|
+
return vaultError({
|
|
2811
|
+
code: "KEY_NOT_FOUND",
|
|
2812
|
+
key
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
const valueEnvelope = typeof valueResult.data?.data === "string" ? JSON.parse(valueResult.data.data) : valueResult.data?.data;
|
|
2816
|
+
const encryptedBytes = base64Decode(valueEnvelope.data);
|
|
2817
|
+
const plaintext = this.crypto.decrypt(entryKey, encryptedBytes);
|
|
2818
|
+
const metadata = valueEnvelope.metadata ?? {};
|
|
2819
|
+
const contentType = metadata[VaultHeaders.CONTENT_TYPE] ?? "application/json";
|
|
2820
|
+
const keyId = metadata[VaultHeaders.KEY_ID] ?? "";
|
|
2821
|
+
let value;
|
|
2822
|
+
if (options?.raw) {
|
|
2823
|
+
value = plaintext;
|
|
2824
|
+
} else if (options?.deserialize) {
|
|
2825
|
+
value = options.deserialize(plaintext);
|
|
2826
|
+
} else if (contentType === "application/json") {
|
|
2827
|
+
value = JSON.parse(fromBytes(plaintext));
|
|
2828
|
+
} else {
|
|
2829
|
+
value = plaintext;
|
|
2830
|
+
}
|
|
2831
|
+
return ok({ value, metadata, keyId });
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
if (error instanceof Error && error.message.includes("decryption")) {
|
|
2834
|
+
return vaultError({
|
|
2835
|
+
code: "DECRYPTION_FAILED",
|
|
2836
|
+
message: error.message
|
|
2837
|
+
});
|
|
2838
|
+
}
|
|
2839
|
+
return vaultError({
|
|
2840
|
+
code: "STORAGE_ERROR",
|
|
2841
|
+
cause: toError(error)
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* Resolve another user's public encryption key from their DID.
|
|
2848
|
+
*
|
|
2849
|
+
* @param did - The DID to resolve (did:pkh:eip155:{chainId}:{address})
|
|
2850
|
+
* @returns Result with the public key bytes
|
|
2851
|
+
*/
|
|
2852
|
+
async resolvePublicKey(did) {
|
|
2853
|
+
try {
|
|
2854
|
+
const parts = this.parseDID(did);
|
|
2855
|
+
if (!parts) {
|
|
2856
|
+
return vaultError({ code: "PUBLIC_KEY_NOT_FOUND", did });
|
|
2857
|
+
}
|
|
2858
|
+
const spaceId = this.tc.makePublicSpaceId(
|
|
2859
|
+
parts.address,
|
|
2860
|
+
parts.chainId
|
|
2861
|
+
);
|
|
2862
|
+
const result = await this.tc.readPublicSpace(
|
|
2863
|
+
this.host,
|
|
2864
|
+
spaceId,
|
|
2865
|
+
".well-known/vault-pubkey"
|
|
2866
|
+
);
|
|
2867
|
+
if (!result.ok) {
|
|
2868
|
+
return vaultError({ code: "PUBLIC_KEY_NOT_FOUND", did });
|
|
2869
|
+
}
|
|
2870
|
+
const pubKeyBytes = base64Decode(result.data);
|
|
2871
|
+
return { ok: true, data: pubKeyBytes };
|
|
2872
|
+
} catch (error) {
|
|
2873
|
+
return vaultError({ code: "PUBLIC_KEY_NOT_FOUND", did });
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* List DIDs that have been granted access to a key.
|
|
2878
|
+
*
|
|
2879
|
+
* @param key - The key to list grants for
|
|
2880
|
+
* @returns Result with array of recipient DIDs
|
|
2881
|
+
*/
|
|
2882
|
+
async listGrants(key) {
|
|
2883
|
+
return this.withTelemetry("listGrants", key, async () => {
|
|
2884
|
+
if (!this._isUnlocked) {
|
|
2885
|
+
return vaultError({
|
|
2886
|
+
code: "VAULT_LOCKED",
|
|
2887
|
+
message: "Vault must be unlocked before listing grants"
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
if (!this.requireAuth()) {
|
|
2891
|
+
return vaultError({
|
|
2892
|
+
code: "VAULT_LOCKED",
|
|
2893
|
+
message: "Authentication required"
|
|
2894
|
+
});
|
|
2895
|
+
}
|
|
2896
|
+
try {
|
|
2897
|
+
const listResult = await this.tc.kv.list({
|
|
2898
|
+
prefix: "grants/",
|
|
2899
|
+
removePrefix: true
|
|
2900
|
+
});
|
|
2901
|
+
if (!listResult.ok) {
|
|
2902
|
+
return vaultError({
|
|
2903
|
+
code: "STORAGE_ERROR",
|
|
2904
|
+
cause: new Error(
|
|
2905
|
+
`Failed to list grants: ${listResult.error.message}`
|
|
2906
|
+
)
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
const dids = [];
|
|
2910
|
+
for (const grantPath of listResult.data.keys) {
|
|
2911
|
+
if (grantPath.endsWith(`/${key}`)) {
|
|
2912
|
+
const did = grantPath.slice(
|
|
2913
|
+
0,
|
|
2914
|
+
grantPath.length - key.length - 1
|
|
2915
|
+
);
|
|
2916
|
+
if (did) {
|
|
2917
|
+
dids.push(did);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
return ok(dids);
|
|
2922
|
+
} catch (error) {
|
|
2923
|
+
return vaultError({
|
|
2924
|
+
code: "STORAGE_ERROR",
|
|
2925
|
+
cause: toError(error)
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
// =========================================================================
|
|
2931
|
+
// Phase 3: Key Rotation / Revocation
|
|
2932
|
+
// =========================================================================
|
|
2933
|
+
/**
|
|
2934
|
+
* Revoke a previously issued grant.
|
|
2935
|
+
*
|
|
2936
|
+
* This performs a full key rotation:
|
|
2937
|
+
* 1. Lists current grantees
|
|
2938
|
+
* 2. Removes the revoked recipient
|
|
2939
|
+
* 3. Re-encrypts the value with a new entry key
|
|
2940
|
+
* 4. Re-issues grants to remaining recipients
|
|
2941
|
+
*
|
|
2942
|
+
* @param key - The key to revoke access to
|
|
2943
|
+
* @param recipientDID - The recipient whose access to revoke
|
|
2944
|
+
*/
|
|
2945
|
+
async revoke(key, recipientDID) {
|
|
2946
|
+
return this.withTelemetry("revoke", key, async () => {
|
|
2947
|
+
if (!this._isUnlocked || !this.masterKey) {
|
|
2948
|
+
return vaultError({
|
|
2949
|
+
code: "VAULT_LOCKED",
|
|
2950
|
+
message: "Vault must be unlocked before revoking access"
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
if (!this.requireAuth()) {
|
|
2954
|
+
return vaultError({
|
|
2955
|
+
code: "VAULT_LOCKED",
|
|
2956
|
+
message: "Authentication required"
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
try {
|
|
2960
|
+
const granteesResult = await this.listGrants(key);
|
|
2961
|
+
if (!granteesResult.ok) {
|
|
2962
|
+
return granteesResult;
|
|
2963
|
+
}
|
|
2964
|
+
const remainingGrantees = granteesResult.data.filter(
|
|
2965
|
+
(did) => did !== recipientDID
|
|
2966
|
+
);
|
|
2967
|
+
const deleteGrantResult = await this.tc.kv.delete(
|
|
2968
|
+
`grants/${recipientDID}/${key}`
|
|
2969
|
+
);
|
|
2970
|
+
const getResult = await this.get(key);
|
|
2971
|
+
if (!getResult.ok) {
|
|
2972
|
+
return getResult;
|
|
2973
|
+
}
|
|
2974
|
+
const currentEntry = getResult.data;
|
|
2975
|
+
const newEntryKey = this.crypto.randomBytes(32);
|
|
2976
|
+
const newKeyId = hexEncode(this.crypto.sha256(newEntryKey)).slice(
|
|
2977
|
+
0,
|
|
2978
|
+
16
|
|
2979
|
+
);
|
|
2980
|
+
let plaintext;
|
|
2981
|
+
if (currentEntry.value instanceof Uint8Array) {
|
|
2982
|
+
plaintext = currentEntry.value;
|
|
2983
|
+
} else {
|
|
2984
|
+
plaintext = toBytes(JSON.stringify(currentEntry.value));
|
|
2985
|
+
}
|
|
2986
|
+
const encrypted = this.crypto.encrypt(newEntryKey, plaintext);
|
|
2987
|
+
const newKeyBlob = this.crypto.encrypt(this.masterKey, newEntryKey);
|
|
2988
|
+
const metadata = {
|
|
2989
|
+
...currentEntry.metadata,
|
|
2990
|
+
[VaultHeaders.KEY_ID]: newKeyId
|
|
2991
|
+
};
|
|
2992
|
+
const keyPayload = JSON.stringify({
|
|
2993
|
+
key: base64Encode(newKeyBlob),
|
|
2994
|
+
metadata: JSON.stringify({
|
|
2995
|
+
keyId: newKeyId,
|
|
2996
|
+
...metadata
|
|
2997
|
+
})
|
|
2998
|
+
});
|
|
2999
|
+
const keyPutResult = await this.tc.kv.put(
|
|
3000
|
+
`keys/${key}`,
|
|
3001
|
+
keyPayload
|
|
3002
|
+
);
|
|
3003
|
+
if (!keyPutResult.ok) {
|
|
3004
|
+
return vaultError({
|
|
3005
|
+
code: "STORAGE_ERROR",
|
|
3006
|
+
cause: new Error(
|
|
3007
|
+
`Failed to store rotated key blob: ${keyPutResult.error.message}`
|
|
3008
|
+
)
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
const valuePayload = JSON.stringify({
|
|
3012
|
+
data: base64Encode(encrypted),
|
|
3013
|
+
metadata
|
|
3014
|
+
});
|
|
3015
|
+
const valuePutResult = await this.tc.kv.put(
|
|
3016
|
+
`vault/${key}`,
|
|
3017
|
+
valuePayload
|
|
3018
|
+
);
|
|
3019
|
+
if (!valuePutResult.ok) {
|
|
3020
|
+
return vaultError({
|
|
3021
|
+
code: "STORAGE_ERROR",
|
|
3022
|
+
cause: new Error(
|
|
3023
|
+
`Failed to store re-encrypted value: ${valuePutResult.error.message}`
|
|
3024
|
+
)
|
|
3025
|
+
});
|
|
3026
|
+
}
|
|
3027
|
+
for (const did of remainingGrantees) {
|
|
3028
|
+
const grantResult = await this.reencrypt(key, did);
|
|
3029
|
+
if (!grantResult.ok) {
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return ok(void 0);
|
|
3033
|
+
} catch (error) {
|
|
3034
|
+
return vaultError({
|
|
3035
|
+
code: "STORAGE_ERROR",
|
|
3036
|
+
cause: toError(error)
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3041
|
+
// =========================================================================
|
|
3042
|
+
// Internal Helpers
|
|
3043
|
+
// =========================================================================
|
|
3044
|
+
/**
|
|
3045
|
+
* Parse a DID string to extract address and chainId.
|
|
3046
|
+
* Expected format: did:pkh:eip155:{chainId}:{address}
|
|
3047
|
+
*
|
|
3048
|
+
* @param did - The DID to parse
|
|
3049
|
+
* @returns Parsed address and chainId, or null if invalid
|
|
3050
|
+
*/
|
|
3051
|
+
parseDID(did) {
|
|
3052
|
+
const parts = did.split(":");
|
|
3053
|
+
if (parts.length !== 5 || parts[0] !== "did" || parts[1] !== "pkh" || parts[2] !== "eip155") {
|
|
3054
|
+
return null;
|
|
3055
|
+
}
|
|
3056
|
+
const chainId = parseInt(parts[3], 10);
|
|
3057
|
+
const address = parts[4];
|
|
3058
|
+
if (isNaN(chainId) || !address) {
|
|
3059
|
+
return null;
|
|
3060
|
+
}
|
|
3061
|
+
return { address, chainId };
|
|
3062
|
+
}
|
|
3063
|
+
};
|
|
3064
|
+
/**
|
|
3065
|
+
* Service identifier for registration.
|
|
3066
|
+
*/
|
|
3067
|
+
DataVaultService.serviceName = "vault";
|
|
3068
|
+
|
|
3069
|
+
// src/vault/createVaultCrypto.ts
|
|
3070
|
+
function createVaultCrypto(wasm) {
|
|
3071
|
+
return {
|
|
3072
|
+
encrypt: (key, plaintext) => wasm.vault_encrypt(key, plaintext),
|
|
3073
|
+
decrypt: (key, blob) => wasm.vault_decrypt(key, blob),
|
|
3074
|
+
deriveKey: (signature, salt, info) => wasm.vault_derive_key(salt, signature, info),
|
|
3075
|
+
x25519FromSeed: (seed) => wasm.vault_x25519_from_seed(seed),
|
|
3076
|
+
x25519Dh: (privateKey, publicKey) => wasm.vault_x25519_dh(privateKey, publicKey),
|
|
3077
|
+
randomBytes: (length) => wasm.vault_random_bytes(length),
|
|
3078
|
+
sha256: (data) => wasm.vault_sha256(data)
|
|
3079
|
+
};
|
|
3080
|
+
}
|
|
3081
|
+
export {
|
|
3082
|
+
BaseService,
|
|
3083
|
+
DataVaultService,
|
|
3084
|
+
DatabaseHandle,
|
|
3085
|
+
DuckDbAction,
|
|
3086
|
+
DuckDbDatabaseHandle,
|
|
3087
|
+
DuckDbService,
|
|
3088
|
+
ErrorCodes,
|
|
3089
|
+
GenericKVResponseSchema,
|
|
3090
|
+
GenericResultSchema,
|
|
3091
|
+
KVAction,
|
|
3092
|
+
KVListResponseSchema,
|
|
3093
|
+
KVListResultSchema,
|
|
3094
|
+
KVResponseHeadersSchema,
|
|
3095
|
+
KVService,
|
|
3096
|
+
PrefixedKVService,
|
|
3097
|
+
RetryPolicySchema,
|
|
3098
|
+
SQLAction,
|
|
3099
|
+
SQLService,
|
|
3100
|
+
ServiceContext,
|
|
3101
|
+
ServiceErrorEventSchema,
|
|
3102
|
+
ServiceErrorSchema,
|
|
3103
|
+
ServiceRequestEventSchema,
|
|
3104
|
+
ServiceResponseEventSchema,
|
|
3105
|
+
ServiceRetryEventSchema,
|
|
3106
|
+
ServiceSessionSchema,
|
|
3107
|
+
TelemetryEvents,
|
|
3108
|
+
TinyCloudQuota,
|
|
3109
|
+
VaultHeaders,
|
|
3110
|
+
VaultPublicSpaceKVActions,
|
|
3111
|
+
abortedError,
|
|
3112
|
+
authExpiredError,
|
|
3113
|
+
authRequiredError,
|
|
3114
|
+
authUnauthorizedError,
|
|
3115
|
+
createKVResponseSchema,
|
|
3116
|
+
createResultSchema,
|
|
3117
|
+
createVaultCrypto,
|
|
3118
|
+
defaultRetryPolicy,
|
|
3119
|
+
err,
|
|
3120
|
+
errorResult,
|
|
3121
|
+
networkError,
|
|
3122
|
+
notFoundError,
|
|
3123
|
+
ok,
|
|
3124
|
+
parseAuthError,
|
|
3125
|
+
permissionDeniedError,
|
|
3126
|
+
serviceError,
|
|
3127
|
+
storageLimitReachedError,
|
|
3128
|
+
storageQuotaExceededError,
|
|
3129
|
+
timeoutError,
|
|
3130
|
+
validateKVListResponse,
|
|
3131
|
+
validateKVResponseHeaders,
|
|
3132
|
+
validateRetryPolicy,
|
|
3133
|
+
validateServiceError,
|
|
3134
|
+
validateServiceRequestEvent,
|
|
3135
|
+
validateServiceResponseEvent,
|
|
3136
|
+
validateServiceSession,
|
|
3137
|
+
wrapError
|
|
3138
|
+
};
|
|
61
3139
|
//# sourceMappingURL=index.js.map
|