@wervt/nuxt 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -0
  2. package/dist/dev.d.ts +3 -1
  3. package/dist/env-types.js +14 -0
  4. package/dist/icons.js +31 -0
  5. package/dist/module.d.ts +8 -0
  6. package/dist/module.js +59 -0
  7. package/dist/node_modules/.pnpm/nitro@3.0.260903-beta/node_modules/nitro/dist/runtime/task.js +1 -2
  8. package/dist/og-image.js +39 -0
  9. package/dist/preview.d.ts +14 -0
  10. package/dist/preview.js +39 -0
  11. package/dist/provision.d.ts +19 -2
  12. package/dist/provision.js +49 -2
  13. package/dist/runtime/app/snapshot.d.ts +17 -0
  14. package/dist/runtime/app/snapshot.js +64 -0
  15. package/dist/runtime/app/useShape.d.ts +8 -1
  16. package/dist/runtime/app/useShape.js +8 -52
  17. package/dist/runtime/app/useWervtFeedback.d.ts +2 -1
  18. package/dist/runtime/email/index.d.ts +2 -1
  19. package/dist/runtime/email/index.js +2 -1
  20. package/dist/runtime/email/invite.d.ts +75 -0
  21. package/dist/runtime/email/invite.js +60 -0
  22. package/dist/runtime/server/access.d.ts +35 -0
  23. package/dist/runtime/server/access.js +88 -0
  24. package/dist/runtime/server/agent-token-plugin.d.ts +13 -0
  25. package/dist/runtime/server/agent-token-plugin.js +10 -0
  26. package/dist/runtime/server/anthropic.d.ts +21 -0
  27. package/dist/runtime/server/anthropic.js +53 -0
  28. package/dist/runtime/server/identity.d.ts +20 -0
  29. package/dist/runtime/server/identity.js +37 -0
  30. package/dist/runtime/server/index.d.ts +5 -3
  31. package/dist/runtime/server/index.js +4 -2
  32. package/dist/runtime/server/task-handlers.d.ts +2 -1
  33. package/dist/runtime/server/tools.d.ts +2 -1
  34. package/dist/runtime/server/user.d.ts +9 -3
  35. package/dist/runtime/server/user.js +28 -3
  36. package/dist/runtime/server/validate.d.ts +2 -1
  37. package/dist/runtime/shared/agent-token.d.ts +9 -0
  38. package/dist/runtime/shared/agent-token.js +17 -0
  39. package/dist/runtime/shared/shape.d.ts +2 -1
  40. package/dist/runtime/shared/user.d.ts +8 -0
  41. package/dist/runtime/shared/user.js +4 -1
  42. package/dist/storage.d.ts +48 -1
  43. package/dist/storage.js +217 -3
  44. package/package.json +15 -6
package/dist/storage.d.ts CHANGED
@@ -13,14 +13,61 @@ export interface StorageUsage {
13
13
  bytes: number;
14
14
  /** Multipart uploads started but never completed or aborted. */
15
15
  unfinishedUploads: number;
16
+ /** The bucket's size limit in bytes, null for none. */
17
+ maxBytes: number | null;
18
+ }
19
+ export interface StorageQuota {
20
+ /** Bytes the bucket may hold; null for no limit. Garage refuses uploads past it. */
21
+ maxBytes: number | null;
16
22
  }
17
23
  export declare class StorageError extends Error {}
24
+ export interface StoredObject {
25
+ key: string;
26
+ size: number;
27
+ /** ISO time. */
28
+ lastModified: string;
29
+ }
30
+ export interface ObjectListing {
31
+ prefix: string;
32
+ /** Prefixes one level below `prefix`, ending in `/`. */
33
+ folders: string[];
34
+ files: StoredObject[];
35
+ /** Pass as `token` for the next page. */
36
+ next?: string;
37
+ }
38
+ export interface ObjectDetails extends StoredObject {
39
+ contentType: string | null;
40
+ /** `x-amz-meta-*` headers, without the prefix. */
41
+ metadata: Record<string, string>;
42
+ }
43
+ /** S3's limit for object keys, in bytes. */
44
+ export declare const MAX_KEY_BYTES = 1024;
18
45
  export declare function createStorage(options: StorageOptions): {
19
46
  ensureLayout: () => Promise<void>;
20
- provision: (app: string, appOrigin: string) => Promise<{
47
+ provision: (app: string, appOrigin: string, quota?: StorageQuota) => Promise<{
21
48
  bucket: string;
22
49
  }>;
50
+ setQuota: (app: string, quota: StorageQuota) => Promise<boolean>;
23
51
  usage: (app: string) => Promise<StorageUsage | undefined>;
52
+ list: (app: string, { prefix, token, limit }?: {
53
+ prefix?: string;
54
+ token?: string;
55
+ limit?: number;
56
+ }) => Promise<ObjectListing | undefined>;
57
+ describe: (app: string, key: string) => Promise<ObjectDetails | undefined>;
58
+ downloadUrl: (app: string, key: string, { publicUrl, expiresIn }: {
59
+ publicUrl: string;
60
+ expiresIn?: number;
61
+ }) => Promise<string>;
62
+ remove: (app: string, key: string) => Promise<{
63
+ deleted: string[];
64
+ } | undefined>;
65
+ sweepAbandonedUploads: (app: string, { graceMs, now }?: {
66
+ graceMs?: number | undefined;
67
+ now?: number | undefined;
68
+ }) => Promise<{
69
+ deleted: string[];
70
+ }>;
24
71
  drop: (app: string) => Promise<void>;
25
72
  };
26
73
  //#endregion
package/dist/storage.js CHANGED
@@ -2,6 +2,75 @@ import { storageCredentials } from "./provision.js";
2
2
  import { AwsClient } from "aws4fetch";
3
3
  //#region src/storage.ts
4
4
  var StorageError = class extends Error {};
5
+ /**
6
+ * ablage marks bytes uploaded straight from browsers (`x-amz-meta-ablage-direct-upload`). Its
7
+ * metadata object (`<group>/meta/<id>`) is only written once `completeUpload()` has checked them,
8
+ * so marked bytes without one belong to an upload that was never completed.
9
+ */
10
+ const DIRECT_UPLOAD_MARKER = "x-amz-meta-ablage-direct-upload";
11
+ /**
12
+ * How old abandoned direct-upload bytes must be before the sweep deletes them. The browser calls
13
+ * `completeUpload()` right after its `PUT` finishes, so a day leaves any upload in flight alone;
14
+ * it matches the lifecycle rule for multipart uploads.
15
+ */
16
+ const ABANDONED_UPLOAD_GRACE_MS = 864e5;
17
+ /** ablage's S3 layout: `<group>/data/<id>` holds the bytes, `<group>/meta/<id>` the metadata. */
18
+ function metaKeyOf(key) {
19
+ const parts = key.split("/");
20
+ if (parts.length < 3 || parts.at(-2) !== "data" || !parts.at(-1)) return void 0;
21
+ return [
22
+ ...parts.slice(0, -2),
23
+ "meta",
24
+ parts.at(-1)
25
+ ].join("/");
26
+ }
27
+ const unescapeXml = (text) => text.replace(/&(amp|lt|gt|quot|apos|#x?[0-9a-f]+);/gi, (_, entity) => {
28
+ const named = {
29
+ amp: "&",
30
+ lt: "<",
31
+ gt: ">",
32
+ quot: "\"",
33
+ apos: "'"
34
+ };
35
+ if (entity in named) return named[entity];
36
+ const hex = entity[1] === "x" || entity[1] === "X";
37
+ return String.fromCodePoint(parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10));
38
+ });
39
+ /**
40
+ * Objects of a ListObjectsV2 page, its folders (listed with a delimiter), and the token for the
41
+ * next page.
42
+ */
43
+ function parseObjectList(xml) {
44
+ const objects = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)].map(([, entry]) => ({
45
+ key: unescapeXml(/<Key>([^<]*)<\/Key>/.exec(entry)?.[1] ?? ""),
46
+ lastModified: Date.parse(/<LastModified>([^<]*)<\/LastModified>/.exec(entry)?.[1] ?? ""),
47
+ size: Number(/<Size>([^<]*)<\/Size>/.exec(entry)?.[1] ?? 0)
48
+ }));
49
+ const prefixes = [...xml.matchAll(/<CommonPrefixes>([\s\S]*?)<\/CommonPrefixes>/g)].map(([, entry]) => unescapeXml(/<Prefix>([^<]*)<\/Prefix>/.exec(entry)?.[1] ?? ""));
50
+ const next = /<IsTruncated>true<\/IsTruncated>/.test(xml) ? /<NextContinuationToken>([^<]*)<\/NextContinuationToken>/.exec(xml)?.[1] : void 0;
51
+ return {
52
+ objects,
53
+ prefixes,
54
+ next: next === void 0 ? void 0 : unescapeXml(next)
55
+ };
56
+ }
57
+ /**
58
+ * ablage keeps a file as bytes and metadata, which only make sense together: the metadata key
59
+ * for bytes, the bytes for metadata, undefined for anything else.
60
+ */
61
+ function companionKeyOf(key) {
62
+ const meta = metaKeyOf(key);
63
+ if (meta !== void 0) return meta;
64
+ const parts = key.split("/");
65
+ if (parts.length < 3 || parts.at(-2) !== "meta" || !parts.at(-1)) return void 0;
66
+ return [
67
+ ...parts.slice(0, -2),
68
+ "data",
69
+ parts.at(-1)
70
+ ].join("/");
71
+ }
72
+ /** S3's limit for object keys, in bytes. */
73
+ const MAX_KEY_BYTES = 1024;
5
74
  function createStorage(options) {
6
75
  async function admin(path, body) {
7
76
  const res = await fetch(`${options.adminUrl}/v2/${path}`, {
@@ -52,8 +121,9 @@ function createStorage(options) {
52
121
  /**
53
122
  * Creates the app's bucket and key if they're missing, lets the app's origin upload and
54
123
  * download from browsers (CORS), and cleans up abandoned uploads. Safe to run on every deploy.
124
+ * With `quota`, sets the bucket's size limit too; without, leaves it as it is.
55
125
  */
56
- async function provision(app, appOrigin) {
126
+ async function provision(app, appOrigin, quota) {
57
127
  const { creds, client, base } = await s3(app);
58
128
  const bucket = await exists(`GetBucketInfo?globalAlias=${creds.bucket}`) ?? await admin("CreateBucket", { globalAlias: creds.bucket });
59
129
  if (!await exists(`GetKeyInfo?id=${creds.accessKeyId}`)) await admin("ImportKey", {
@@ -70,6 +140,7 @@ function createStorage(options) {
70
140
  owner: true
71
141
  }
72
142
  });
143
+ if (quota) await applyQuota(bucket.id, quota);
73
144
  const cors = `<CORSConfiguration><CORSRule>
74
145
  <AllowedOrigin>${appOrigin}</AllowedOrigin>
75
146
  <AllowedMethod>GET</AllowedMethod><AllowedMethod>HEAD</AllowedMethod>
@@ -92,6 +163,20 @@ function createStorage(options) {
92
163
  if (!rules.ok) throw new StorageError(`setting the lifecycle for ${creds.bucket}: ${await rules.text()}`);
93
164
  return { bucket: creds.bucket };
94
165
  }
166
+ function applyQuota(bucketId, quota) {
167
+ return admin(`UpdateBucket?id=${bucketId}`, { quotas: {
168
+ maxSize: quota.maxBytes,
169
+ maxObjects: null
170
+ } });
171
+ }
172
+ /** Changes the size limit of an existing bucket. False when the app has no bucket yet. */
173
+ async function setQuota(app, quota) {
174
+ const { creds } = await s3(app);
175
+ const bucket = await exists(`GetBucketInfo?globalAlias=${creds.bucket}`);
176
+ if (!bucket) return false;
177
+ await applyQuota(bucket.id, quota);
178
+ return true;
179
+ }
95
180
  async function usage(app) {
96
181
  const { creds } = await s3(app);
97
182
  const info = await exists(`GetBucketInfo?globalAlias=${creds.bucket}`);
@@ -100,9 +185,48 @@ function createStorage(options) {
100
185
  bucket: creds.bucket,
101
186
  objects: info.objects,
102
187
  bytes: info.bytes,
103
- unfinishedUploads: info.unfinishedMultipartUploads
188
+ unfinishedUploads: info.unfinishedMultipartUploads,
189
+ maxBytes: info.quotas?.maxSize ?? null
104
190
  };
105
191
  }
192
+ /**
193
+ * Deletes the bytes of direct uploads that were never completed: marked by ablage, older than
194
+ * `graceMs` and without a metadata object. ablage never lists them, but they count against the
195
+ * bucket quota. Safe to run any time and as often as wanted.
196
+ */
197
+ async function sweepAbandonedUploads(app, { graceMs = ABANDONED_UPLOAD_GRACE_MS, now = Date.now() } = {}) {
198
+ const { creds, client, base } = await s3(app);
199
+ if (!await exists(`GetBucketInfo?globalAlias=${creds.bucket}`)) return { deleted: [] };
200
+ const url = (key) => `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
201
+ const candidates = /* @__PURE__ */ new Map();
202
+ let token;
203
+ do {
204
+ const query = `list-type=2&max-keys=1000${token ? `&continuation-token=${encodeURIComponent(token)}` : ""}`;
205
+ const res = await client.fetch(`${base}?${query}`);
206
+ if (!res.ok) throw new StorageError(`listing ${creds.bucket}: ${await res.text()}`);
207
+ const page = parseObjectList(await res.text());
208
+ for (const object of page.objects) {
209
+ candidates.delete(object.key);
210
+ const metaKey = metaKeyOf(object.key);
211
+ if (metaKey !== void 0 && now - object.lastModified >= graceMs) candidates.set(metaKey, object.key);
212
+ }
213
+ token = page.next;
214
+ } while (token);
215
+ const deleted = [];
216
+ for (const [metaKey, key] of candidates) {
217
+ const head = await client.fetch(url(key), { method: "HEAD" });
218
+ if (head.status === 404) continue;
219
+ if (!head.ok) throw new StorageError(`checking ${key} in ${creds.bucket}: ${head.status}`);
220
+ if (!head.headers.get(DIRECT_UPLOAD_MARKER)) continue;
221
+ const meta = await client.fetch(url(metaKey), { method: "HEAD" });
222
+ if (meta.ok) continue;
223
+ if (meta.status !== 404) throw new StorageError(`checking ${metaKey} in ${creds.bucket}: ${meta.status}`);
224
+ const res = await client.fetch(url(key), { method: "DELETE" });
225
+ if (!res.ok && res.status !== 404) throw new StorageError(`deleting ${key} in ${creds.bucket}: ${await res.text()}`);
226
+ deleted.push(key);
227
+ }
228
+ return { deleted };
229
+ }
106
230
  /** Deletes every object and unfinished upload, then the bucket. Only with "drop data". */
107
231
  async function drop(app) {
108
232
  const { creds, client, base } = await s3(app);
@@ -133,12 +257,102 @@ function createStorage(options) {
133
257
  await admin(`DeleteBucket?id=${String(bucket.id)}`, {});
134
258
  }
135
259
  }
260
+ const objectUrl = (base, key) => `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
261
+ /**
262
+ * One page of the bucket, one folder level at a time like a file browser: the files directly
263
+ * in `prefix` and the folders below it. Undefined when the app has no bucket.
264
+ */
265
+ async function list(app, { prefix = "", token, limit = 200 } = {}) {
266
+ const { creds, client, base } = await s3(app);
267
+ const query = new URLSearchParams({
268
+ "list-type": "2",
269
+ delimiter: "/",
270
+ prefix,
271
+ "max-keys": String(Math.min(Math.max(limit, 1), 1e3))
272
+ });
273
+ if (token) query.set("continuation-token", token);
274
+ const res = await client.fetch(`${base}?${query.toString()}`);
275
+ if (res.status === 404) return void 0;
276
+ if (!res.ok) throw new StorageError(`listing ${creds.bucket}: ${await res.text()}`);
277
+ const page = parseObjectList(await res.text());
278
+ return {
279
+ prefix,
280
+ folders: page.prefixes,
281
+ files: page.objects.map((object) => ({
282
+ key: object.key,
283
+ size: object.size,
284
+ lastModified: new Date(object.lastModified).toISOString()
285
+ })),
286
+ next: page.next
287
+ };
288
+ }
289
+ /** Size, type and metadata of one object; undefined when it doesn't exist. */
290
+ async function describe(app, key) {
291
+ const { creds, client, base } = await s3(app);
292
+ const res = await client.fetch(objectUrl(base, key), { method: "HEAD" });
293
+ if (res.status === 404) return void 0;
294
+ if (!res.ok) throw new StorageError(`reading ${key} in ${creds.bucket}: ${res.status}`);
295
+ const metadata = {};
296
+ for (const [name, value] of res.headers) if (name.startsWith("x-amz-meta-")) metadata[name.slice(11)] = value;
297
+ const modified = res.headers.get("last-modified");
298
+ return {
299
+ key,
300
+ size: Number(res.headers.get("content-length") ?? 0),
301
+ lastModified: modified ? new Date(modified).toISOString() : "",
302
+ contentType: res.headers.get("content-type"),
303
+ metadata
304
+ };
305
+ }
306
+ /**
307
+ * A presigned download link on `publicUrl`, the S3 API as browsers reach it, so files go from
308
+ * Garage straight to the browser. Always an attachment, never shown inline: the file is the
309
+ * app's users' content and may well be HTML.
310
+ */
311
+ async function downloadUrl(app, key, { publicUrl, expiresIn = 300 }) {
312
+ const { creds, client } = await s3(app);
313
+ const url = new URL(objectUrl(`${publicUrl.replace(/\/$/, "")}/${creds.bucket}`, key));
314
+ const name = key.split("/").at(-1) || "download";
315
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
316
+ url.searchParams.set("response-content-disposition", `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`);
317
+ url.searchParams.set("X-Amz-Expires", String(expiresIn));
318
+ return (await client.sign(url.toString(), {
319
+ method: "GET",
320
+ aws: { signQuery: true }
321
+ })).url;
322
+ }
323
+ /**
324
+ * Deletes one object, and its ablage companion (see companionKeyOf) so ablage never lists a
325
+ * file whose bytes are gone. Undefined when the object doesn't exist.
326
+ */
327
+ async function remove(app, key) {
328
+ const { creds, client, base } = await s3(app);
329
+ const present = async (k) => {
330
+ const res = await client.fetch(objectUrl(base, k), { method: "HEAD" });
331
+ if (res.status === 404) return false;
332
+ if (!res.ok) throw new StorageError(`checking ${k} in ${creds.bucket}: ${res.status}`);
333
+ return true;
334
+ };
335
+ if (!await present(key)) return void 0;
336
+ const companion = companionKeyOf(key);
337
+ const keys = companion !== void 0 && await present(companion) ? [key, companion] : [key];
338
+ for (const k of keys) {
339
+ const res = await client.fetch(objectUrl(base, k), { method: "DELETE" });
340
+ if (!res.ok && res.status !== 404) throw new StorageError(`deleting ${k} in ${creds.bucket}: ${await res.text()}`);
341
+ }
342
+ return { deleted: keys };
343
+ }
136
344
  return {
137
345
  ensureLayout,
138
346
  provision,
347
+ setQuota,
139
348
  usage,
349
+ list,
350
+ describe,
351
+ downloadUrl,
352
+ remove,
353
+ sweepAbandonedUploads,
140
354
  drop
141
355
  };
142
356
  }
143
357
  //#endregion
144
- export { StorageError, createStorage };
358
+ export { ABANDONED_UPLOAD_GRACE_MS, MAX_KEY_BYTES, StorageError, companionKeyOf, createStorage, metaKeyOf, parseObjectList };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wervt/nuxt",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Nuxt module for apps hosted on wervt: database, live data, MCP and runtime setup.",
5
5
  "keywords": [
6
6
  "drizzle",
@@ -37,6 +37,10 @@
37
37
  "types": "./dist/runtime/email/index.d.ts",
38
38
  "default": "./dist/runtime/email/index.js"
39
39
  },
40
+ "./anthropic": {
41
+ "types": "./dist/runtime/server/anthropic.d.ts",
42
+ "default": "./dist/runtime/server/anthropic.js"
43
+ },
40
44
  "./provision": {
41
45
  "types": "./dist/provision.d.ts",
42
46
  "default": "./dist/provision.js"
@@ -46,7 +50,7 @@
46
50
  "@electric-sql/client": "^1.5.28",
47
51
  "@valibot/to-json-schema": "^1.8.0",
48
52
  "aws4fetch": "^1.0.20",
49
- "evlog": "^2.30.1",
53
+ "evlog": "^2.33.1",
50
54
  "h3-mcp": "0.2.0",
51
55
  "html-to-text": "^9.0.5",
52
56
  "jose": "^6.2.12",
@@ -54,19 +58,24 @@
54
58
  "valibot": "^1.5.0"
55
59
  },
56
60
  "devDependencies": {
61
+ "@anthropic-ai/sdk": "^0.97.1",
57
62
  "@types/html-to-text": "^9.0.4",
58
- "drizzle-orm": "1.0.0-rc.4",
59
- "h3": "2.0.2",
63
+ "drizzle-orm": "1.0.0-rc.5",
64
+ "h3": "2.0.5",
60
65
  "nitro": "3.0.260903-beta",
61
66
  "nuxt": "npm:nuxt-nightly@5x",
62
- "vue": "^3.5.43"
67
+ "vue": "^3.5.44"
63
68
  },
64
69
  "peerDependencies": {
65
- "drizzle-orm": "1.0.0-rc.4",
70
+ "@anthropic-ai/sdk": ">=0.97.1",
71
+ "drizzle-orm": ">=1.0.0-rc.5 <2",
66
72
  "h3": "^2.0.1",
67
73
  "nuxt": ">=4.2"
68
74
  },
69
75
  "peerDependenciesMeta": {
76
+ "@anthropic-ai/sdk": {
77
+ "optional": true
78
+ },
70
79
  "h3": {
71
80
  "optional": true
72
81
  }