@selvajs/platform 0.14.2 → 0.14.3-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.
Files changed (85) hide show
  1. package/dist/computeServer/index.d.ts +2 -0
  2. package/dist/computeServer/index.d.ts.map +1 -1
  3. package/dist/computeServer/index.js +6 -0
  4. package/dist/computeServer/index.js.map +1 -1
  5. package/dist/computeServer/interface.d.ts +10 -0
  6. package/dist/computeServer/interface.d.ts.map +1 -1
  7. package/dist/computeServer/secrets.d.ts +53 -0
  8. package/dist/computeServer/secrets.d.ts.map +1 -0
  9. package/dist/computeServer/secrets.js +67 -0
  10. package/dist/computeServer/secrets.js.map +1 -0
  11. package/dist/context.d.ts +7 -0
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +13 -0
  14. package/dist/context.js.map +1 -1
  15. package/dist/definitions/interface.d.ts +8 -0
  16. package/dist/definitions/interface.d.ts.map +1 -1
  17. package/dist/errors/interface.d.ts +44 -0
  18. package/dist/errors/interface.d.ts.map +1 -0
  19. package/dist/errors/interface.js +9 -0
  20. package/dist/errors/interface.js.map +1 -0
  21. package/dist/index.d.ts +11 -4
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +6 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/organizations/index.d.ts +4 -3
  26. package/dist/organizations/index.d.ts.map +1 -1
  27. package/dist/organizations/index.js +2 -1
  28. package/dist/organizations/index.js.map +1 -1
  29. package/dist/organizations/interface.d.ts +1 -1
  30. package/dist/organizations/interface.d.ts.map +1 -1
  31. package/dist/organizations/paths.d.ts +22 -0
  32. package/dist/organizations/paths.d.ts.map +1 -0
  33. package/dist/organizations/paths.js +43 -0
  34. package/dist/organizations/paths.js.map +1 -0
  35. package/dist/organizations/schemas.d.ts +10 -0
  36. package/dist/organizations/schemas.d.ts.map +1 -1
  37. package/dist/organizations/schemas.js +6 -0
  38. package/dist/organizations/schemas.js.map +1 -1
  39. package/dist/organizations/types.d.ts +10 -1
  40. package/dist/organizations/types.d.ts.map +1 -1
  41. package/dist/storage/assetClasses.d.ts +59 -0
  42. package/dist/storage/assetClasses.d.ts.map +1 -0
  43. package/dist/storage/assetClasses.js +86 -0
  44. package/dist/storage/assetClasses.js.map +1 -0
  45. package/dist/storage/cacheBust.d.ts +21 -0
  46. package/dist/storage/cacheBust.d.ts.map +1 -0
  47. package/dist/storage/cacheBust.js +29 -0
  48. package/dist/storage/cacheBust.js.map +1 -0
  49. package/dist/storage/image.d.ts +6 -1
  50. package/dist/storage/image.d.ts.map +1 -1
  51. package/dist/storage/image.js +8 -3
  52. package/dist/storage/image.js.map +1 -1
  53. package/dist/storage/index.d.ts +3 -0
  54. package/dist/storage/index.d.ts.map +1 -1
  55. package/dist/storage/index.js +2 -0
  56. package/dist/storage/index.js.map +1 -1
  57. package/dist/testing/suites/definitionStoreSuite.d.ts.map +1 -1
  58. package/dist/testing/suites/definitionStoreSuite.js +30 -0
  59. package/dist/testing/suites/definitionStoreSuite.js.map +1 -1
  60. package/dist/testing/suites/orgStoreSuite.d.ts.map +1 -1
  61. package/dist/testing/suites/orgStoreSuite.js +12 -0
  62. package/dist/testing/suites/orgStoreSuite.js.map +1 -1
  63. package/dist/testing/suites/storageProviderSuite.d.ts.map +1 -1
  64. package/dist/testing/suites/storageProviderSuite.js +18 -0
  65. package/dist/testing/suites/storageProviderSuite.js.map +1 -1
  66. package/package.json +2 -2
  67. package/src/computeServer/index.ts +11 -0
  68. package/src/computeServer/interface.ts +11 -0
  69. package/src/computeServer/secrets.ts +102 -0
  70. package/src/context.ts +14 -0
  71. package/src/definitions/interface.ts +9 -0
  72. package/src/errors/interface.ts +45 -0
  73. package/src/index.ts +30 -2
  74. package/src/organizations/index.ts +11 -2
  75. package/src/organizations/interface.ts +1 -1
  76. package/src/organizations/paths.ts +45 -0
  77. package/src/organizations/schemas.ts +9 -0
  78. package/src/organizations/types.ts +11 -1
  79. package/src/storage/assetClasses.ts +134 -0
  80. package/src/storage/cacheBust.ts +26 -0
  81. package/src/storage/image.ts +8 -3
  82. package/src/storage/index.ts +3 -0
  83. package/src/testing/suites/definitionStoreSuite.ts +45 -0
  84. package/src/testing/suites/orgStoreSuite.ts +17 -0
  85. package/src/testing/suites/storageProviderSuite.ts +21 -0
@@ -0,0 +1,134 @@
1
+ import { ALL_ORG_ASSET_KINDS } from '../organizations/schemas.js';
2
+ import { COVER_IMAGE_EXTENSIONS } from '../definitions/types.js';
3
+
4
+ /**
5
+ * How a stored asset is allowed to be read. This is a property of the asset
6
+ * *class* (its path prefix), never of the individual file — so a logo and a
7
+ * pricing sheet differ only in which class they belong to, and adding a new
8
+ * asset type is one entry in {@link ASSET_CLASSES} rather than a new route.
9
+ *
10
+ * - `public` — anyone, including logged-out visitors. Served with no auth via
11
+ * a CDN/public bucket (Supabase) or a shape-gated proxy branch (local). Used
12
+ * for branding (logo, favicon) that appears on viewer headers and login pages.
13
+ * - `org` — members of the owning org only. The route resolves the orgId
14
+ * from the path and runs an org-membership check. Used for org-private docs
15
+ * (e.g. pricing sheets) once they exist.
16
+ * - `project` — members of the owning project only. The route resolves the
17
+ * definition guid → its project and runs the project-view check. Used for
18
+ * definition cover images and other per-definition blobs.
19
+ */
20
+ export type AssetVisibility = 'public' | 'org' | 'project';
21
+
22
+ /**
23
+ * What scope id the route must authorize against. `none` for public assets
24
+ * (no scope to check); `org`/`project` carry the id extracted from the path.
25
+ */
26
+ export type AssetScope = 'none' | 'org' | 'project';
27
+
28
+ export interface AssetClass {
29
+ /** Stable identifier — for logging and tests, not user-facing. */
30
+ readonly id: string;
31
+ readonly visibility: AssetVisibility;
32
+ readonly scope: AssetScope;
33
+ /**
34
+ * Anchored matcher for this class's storage paths. Capture group 1 (when
35
+ * present) is the scope id (orgId or guid) the route authorizes against.
36
+ */
37
+ readonly pattern: RegExp;
38
+ }
39
+
40
+ export interface AssetMatch {
41
+ readonly class: AssetClass;
42
+ /** The scope id from the path, or null for `scope: 'none'` classes. */
43
+ readonly scopeId: string | null;
44
+ }
45
+
46
+ // Org branding kinds (logo, favicon, …) are the public set. Built from the
47
+ // schema enum so adding a kind there extends this pattern automatically.
48
+ const BRANDING_KINDS = ALL_ORG_ASSET_KINDS.join('|');
49
+
50
+ // Cover extensions are derived from the canonical `COVER_IMAGE_EXTENSIONS`
51
+ // (definitions/types.ts) — strip the leading dot and escape so adding a new
52
+ // cover format there propagates here instead of silently 404ing legit covers.
53
+ const COVER_EXTS = COVER_IMAGE_EXTENSIONS.map((ext) =>
54
+ ext.replace(/^\./, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
55
+ ).join('|');
56
+
57
+ // Reusable segment alphabets. `SAFE_SEGMENT` mirrors the `assertSafeKey`
58
+ // alphabet in the path helpers (traversal/empty segments are rejected
59
+ // separately by `hasUnsafeSegment`); `GUID` is the canonical definition guid
60
+ // shape — kept in sync with `UUID_REGEX` in definitions/schemas.ts.
61
+ const SAFE_SEGMENT = String.raw`[A-Za-z0-9._-]+`;
62
+ const GUID = String.raw`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`;
63
+
64
+ /**
65
+ * The closed registry of servable asset classes. Order matters only for
66
+ * readability — patterns are mutually exclusive by prefix. Anything that
67
+ * matches no class is unservable (the route 404s), which keeps the proxy
68
+ * default-deny: a new blob type is opt-in by adding an entry here.
69
+ */
70
+ export const ASSET_CLASSES: readonly AssetClass[] = [
71
+ {
72
+ id: 'org-branding',
73
+ visibility: 'public',
74
+ scope: 'none',
75
+ pattern: new RegExp(
76
+ String.raw`^orgs\/(${SAFE_SEGMENT})\/branding\/(?:${BRANDING_KINDS})\.webp$`
77
+ )
78
+ },
79
+ {
80
+ id: 'org-private',
81
+ visibility: 'org',
82
+ scope: 'org',
83
+ pattern: new RegExp(String.raw`^orgs\/(${SAFE_SEGMENT})\/private\/${SAFE_SEGMENT}$`)
84
+ },
85
+ {
86
+ id: 'definition-cover',
87
+ visibility: 'project',
88
+ scope: 'project',
89
+ pattern: new RegExp(String.raw`^definitions\/(${GUID})\/cover\.(?:${COVER_EXTS})$`, 'i')
90
+ }
91
+ ];
92
+
93
+ /**
94
+ * Reject any path that contains a traversal, empty, or backslash segment
95
+ * *before* it can match a class. The class patterns use a permissive segment
96
+ * alphabet (`[A-Za-z0-9._-]+`) so dotted ids and filenames work, but that
97
+ * alphabet also admits a bare `.` or `..` *as a whole segment* — which would
98
+ * let `orgs/../branding/logo.webp` classify as public branding with `..`
99
+ * captured as the orgId. This mirrors `assertSafeKey` in the path builders:
100
+ * the registry is the read-side gate, those are the write-side gate, and both
101
+ * forbid the same segments so a path that can't be *written* can't be *read*.
102
+ */
103
+ function hasUnsafeSegment(storagePath: string): boolean {
104
+ if (storagePath.includes('\\')) return true;
105
+ for (const segment of storagePath.split('/')) {
106
+ if (segment === '' || segment === '.' || segment === '..') return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * Classify a storage path into its asset class and scope id, or null when no
113
+ * class matches (or the path is unsafe). Callers — `getPublicUrl` (to pick CDN
114
+ * vs proxy) and the serving route (to pick the auth check) — consult this so
115
+ * URL generation and authorization never drift apart.
116
+ *
117
+ * For `org-branding` the orgId is captured as group 1 but the class is public,
118
+ * so `scopeId` is reported as the captured id only when the class actually
119
+ * authorizes against it (`scope !== 'none'`); public classes report null.
120
+ */
121
+ export function classifyAssetPath(storagePath: string): AssetMatch | null {
122
+ if (hasUnsafeSegment(storagePath)) return null;
123
+ for (const cls of ASSET_CLASSES) {
124
+ const m = cls.pattern.exec(storagePath);
125
+ if (!m) continue;
126
+ return { class: cls, scopeId: cls.scope === 'none' ? null : (m[1] ?? null) };
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** True when the path belongs to a `public` asset class (CDN-servable, no auth). */
132
+ export function isPublicAssetPath(storagePath: string): boolean {
133
+ return classifyAssetPath(storagePath)?.class.visibility === 'public';
134
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Append a cache-busting token to a stored-asset URL.
3
+ *
4
+ * Mutable blobs are overwritten **in place** (`IStorageProvider.put` is upsert),
5
+ * so a re-upload keeps the same storage path — and therefore the same public
6
+ * URL. A browser/CDN that cached the old bytes under that URL keeps serving them
7
+ * until the TTL expires, which reads as "the replace didn't work". Appending a
8
+ * token that changes only when the content changes gives each new version a
9
+ * distinct URL (forcing a refetch) while letting unchanged assets stay cached.
10
+ *
11
+ * Generic over asset kind: the caller supplies the token from whatever it has
12
+ * that tracks the blob's version — a content hash, the owning record's
13
+ * `updatedAt`, a version counter. The serving route ignores the query string
14
+ * (SvelteKit's `[...path]` param never includes it), so this is purely a
15
+ * client-cache key and needs no route support.
16
+ *
17
+ * Empty/whitespace tokens are dropped (returns the URL unchanged) so a missing
18
+ * token degrades to "no cache-bust" rather than `?v=`.
19
+ */
20
+ export function withCacheBust(url: string, token: string | number | undefined | null): string {
21
+ if (token === undefined || token === null) return url;
22
+ const value = String(token).trim();
23
+ if (value === '') return url;
24
+ const sep = url.includes('?') ? '&' : '?';
25
+ return `${url}${sep}v=${encodeURIComponent(value)}`;
26
+ }
@@ -22,12 +22,17 @@ export interface TranscodeResult {
22
22
  /** True if the input looks like an image to process — by content type or path extension. */
23
23
  export function isImageUpload(contentType: string | undefined, storagePath: string): boolean {
24
24
  if (contentType?.startsWith('image/')) return true;
25
- return /\.(webp|png|jpe?g|gif|bmp|tif?f)$/i.test(storagePath);
25
+ return /\.(webp|png|jpe?g|gif|bmp|tif?f|svg)$/i.test(storagePath);
26
26
  }
27
27
 
28
- /** Rewrite a path's extension to `.webp`. Unchanged if not a recognized image extension. */
28
+ /**
29
+ * Rewrite a path's extension to `.webp`. Unchanged if not a recognized image
30
+ * extension. SVG is included: every upload (including SVG) is rasterized to
31
+ * WebP, so a `.svg` input is stored as `.webp` — no vector blob is ever
32
+ * persisted, which keeps the served bytes XSS-free without a sanitizer.
33
+ */
29
34
  export function toWebpPath(storagePath: string): string {
30
- return storagePath.replace(/\.(png|jpe?g|gif|bmp|tif?f)$/i, '.webp');
35
+ return storagePath.replace(/\.(png|jpe?g|gif|bmp|tif?f|svg)$/i, '.webp');
31
36
  }
32
37
 
33
38
  /**
@@ -7,3 +7,6 @@ export {
7
7
  transcodeImageIfNeeded
8
8
  } from './image.js';
9
9
  export type { TranscodeResult } from './image.js';
10
+ export { ASSET_CLASSES, classifyAssetPath, isPublicAssetPath } from './assetClasses.js';
11
+ export type { AssetVisibility, AssetScope, AssetClass, AssetMatch } from './assetClasses.js';
12
+ export { withCacheBust } from './cacheBust.js';
@@ -522,6 +522,51 @@ export function runDefinitionStoreConformance(opts: DefinitionStoreConformanceOp
522
522
  expect(byProject.items.map((r) => r.guid)).not.toContain(guid);
523
523
  });
524
524
 
525
+ it('deleteByProject soft-deletes every definition in the project, leaving others', async () => {
526
+ const store = await createStore();
527
+ const scope = await scopeFor();
528
+ // Two published definitions in the target project, one in another —
529
+ // the cascade must tombstone the target project's and leave the rest.
530
+ const a = makeUuid();
531
+ const b = makeUuid();
532
+ const other = makeUuid();
533
+ await store.create(
534
+ ctx(scope.ownerId),
535
+ record(scope, { guid: a, projectId: scope.projectId, status: 'published' })
536
+ );
537
+ await store.create(
538
+ ctx(scope.ownerId),
539
+ record(scope, { guid: b, projectId: scope.projectId, status: 'published' })
540
+ );
541
+ await store.create(
542
+ ctx(scope.ownerId),
543
+ record(scope, { guid: other, projectId: scope.secondaryProjectId, status: 'published' })
544
+ );
545
+
546
+ await store.deleteByProject(ctx(scope.ownerId), scope.projectId);
547
+
548
+ // Both target-project definitions gone from every read path…
549
+ expect(await store.get(ctx(scope.ownerId), a)).toBeNull();
550
+ expect(await store.get(ctx(scope.ownerId), b)).toBeNull();
551
+ const targetList = await store.listByProject(ctx(scope.ownerId), scope.projectId, {
552
+ limit: 500
553
+ });
554
+ expect(targetList.items).toHaveLength(0);
555
+ const all = await store.list(ctx(scope.ownerId), { limit: 500 });
556
+ expect(all.items.map((r) => r.guid)).not.toContain(a);
557
+ expect(all.items.map((r) => r.guid)).not.toContain(b);
558
+ // …but the other project's definition survives.
559
+ expect(await store.get(ctx(scope.ownerId), other)).not.toBeNull();
560
+ });
561
+
562
+ it('deleteByProject is a no-op when the project has no definitions', async () => {
563
+ const store = await createStore();
564
+ const scope = await scopeFor();
565
+ await expect(
566
+ store.deleteByProject(ctx(scope.ownerId), scope.secondaryProjectId)
567
+ ).resolves.toBeUndefined();
568
+ });
569
+
525
570
  it('listPublic returns published records, not pending', async () => {
526
571
  const store = await createStore();
527
572
  const scope = await scopeFor();
@@ -132,6 +132,23 @@ export function runOrgStoreConformance(opts: OrgStoreConformanceOptions): void {
132
132
  expect(got?.slug).toBe('new-slug');
133
133
  });
134
134
 
135
+ it('updateOrg sets and clears branding assets', async () => {
136
+ const store = await createStore();
137
+ const u1 = await seed();
138
+ const orgId = makeUuid();
139
+ await store.createOrg(ctx(u1), org(u1, { id: orgId, slug: 'asset-org' }));
140
+
141
+ // Set the logo asset.
142
+ await store.updateOrg(ctx(u1), orgId, { assets: { logo: '/api/files/orgs/x/logo.webp' } });
143
+ expect((await store.getOrg(ctx(u1), orgId))?.assets?.logo).toBe(
144
+ '/api/files/orgs/x/logo.webp'
145
+ );
146
+
147
+ // Replace the whole map (drops logo) — the store persists what it's given.
148
+ await store.updateOrg(ctx(u1), orgId, { assets: {} });
149
+ expect((await store.getOrg(ctx(u1), orgId))?.assets?.logo ?? null).toBeNull();
150
+ });
151
+
135
152
  it('deleteOrg removes the organization', async () => {
136
153
  const store = await createStore();
137
154
  const u1 = await seed();
@@ -171,5 +171,26 @@ export function runStorageProviderConformance(opts: StorageProviderConformanceOp
171
171
  expect(atWebp?.slice(0, 4)).toEqual(bytes('RIFF'));
172
172
  expect(atWebp?.slice(8, 12)).toEqual(bytes('WEBP'));
173
173
  });
174
+
175
+ it('rasterizes svg input to webp — no vector blob is ever stored', async () => {
176
+ const storage = await createStorage();
177
+ // A minimal SVG carrying a <script> — the exact XSS payload the
178
+ // rasterize-everything policy is meant to neutralize.
179
+ const svg =
180
+ '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">' +
181
+ '<script>alert(1)</script><rect width="10" height="10" fill="red"/></svg>';
182
+ const svgBytes = new TextEncoder().encode(svg);
183
+ await storage.put('orgs/x/logo.webp', svgBytes, 'image/svg+xml');
184
+
185
+ // The stored blob is a WebP raster, NOT the original SVG bytes — so the
186
+ // served file can't execute script. (The path is already .webp because
187
+ // orgPaths.asset fixes it; the content is what matters here.)
188
+ const stored = await storage.get('orgs/x/logo.webp');
189
+ expect(stored).toBeTruthy();
190
+ expect(stored?.slice(0, 4)).toEqual(bytes('RIFF'));
191
+ expect(stored?.slice(8, 12)).toEqual(bytes('WEBP'));
192
+ // Defense-in-depth assertion: the script tag is gone from the bytes.
193
+ expect(new TextDecoder().decode(stored!)).not.toContain('script');
194
+ });
174
195
  });
175
196
  }