@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.
- package/dist/computeServer/index.d.ts +2 -0
- package/dist/computeServer/index.d.ts.map +1 -1
- package/dist/computeServer/index.js +6 -0
- package/dist/computeServer/index.js.map +1 -1
- package/dist/computeServer/interface.d.ts +10 -0
- package/dist/computeServer/interface.d.ts.map +1 -1
- package/dist/computeServer/secrets.d.ts +53 -0
- package/dist/computeServer/secrets.d.ts.map +1 -0
- package/dist/computeServer/secrets.js +67 -0
- package/dist/computeServer/secrets.js.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +13 -0
- package/dist/context.js.map +1 -1
- package/dist/definitions/interface.d.ts +8 -0
- package/dist/definitions/interface.d.ts.map +1 -1
- package/dist/errors/interface.d.ts +44 -0
- package/dist/errors/interface.d.ts.map +1 -0
- package/dist/errors/interface.js +9 -0
- package/dist/errors/interface.js.map +1 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/organizations/index.d.ts +4 -3
- package/dist/organizations/index.d.ts.map +1 -1
- package/dist/organizations/index.js +2 -1
- package/dist/organizations/index.js.map +1 -1
- package/dist/organizations/interface.d.ts +1 -1
- package/dist/organizations/interface.d.ts.map +1 -1
- package/dist/organizations/paths.d.ts +22 -0
- package/dist/organizations/paths.d.ts.map +1 -0
- package/dist/organizations/paths.js +43 -0
- package/dist/organizations/paths.js.map +1 -0
- package/dist/organizations/schemas.d.ts +10 -0
- package/dist/organizations/schemas.d.ts.map +1 -1
- package/dist/organizations/schemas.js +6 -0
- package/dist/organizations/schemas.js.map +1 -1
- package/dist/organizations/types.d.ts +10 -1
- package/dist/organizations/types.d.ts.map +1 -1
- package/dist/storage/assetClasses.d.ts +59 -0
- package/dist/storage/assetClasses.d.ts.map +1 -0
- package/dist/storage/assetClasses.js +86 -0
- package/dist/storage/assetClasses.js.map +1 -0
- package/dist/storage/cacheBust.d.ts +21 -0
- package/dist/storage/cacheBust.d.ts.map +1 -0
- package/dist/storage/cacheBust.js +29 -0
- package/dist/storage/cacheBust.js.map +1 -0
- package/dist/storage/image.d.ts +6 -1
- package/dist/storage/image.d.ts.map +1 -1
- package/dist/storage/image.js +8 -3
- package/dist/storage/image.js.map +1 -1
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -1
- package/dist/testing/suites/definitionStoreSuite.d.ts.map +1 -1
- package/dist/testing/suites/definitionStoreSuite.js +30 -0
- package/dist/testing/suites/definitionStoreSuite.js.map +1 -1
- package/dist/testing/suites/orgStoreSuite.d.ts.map +1 -1
- package/dist/testing/suites/orgStoreSuite.js +12 -0
- package/dist/testing/suites/orgStoreSuite.js.map +1 -1
- package/dist/testing/suites/storageProviderSuite.d.ts.map +1 -1
- package/dist/testing/suites/storageProviderSuite.js +18 -0
- package/dist/testing/suites/storageProviderSuite.js.map +1 -1
- package/package.json +2 -2
- package/src/computeServer/index.ts +11 -0
- package/src/computeServer/interface.ts +11 -0
- package/src/computeServer/secrets.ts +102 -0
- package/src/context.ts +14 -0
- package/src/definitions/interface.ts +9 -0
- package/src/errors/interface.ts +45 -0
- package/src/index.ts +30 -2
- package/src/organizations/index.ts +11 -2
- package/src/organizations/interface.ts +1 -1
- package/src/organizations/paths.ts +45 -0
- package/src/organizations/schemas.ts +9 -0
- package/src/organizations/types.ts +11 -1
- package/src/storage/assetClasses.ts +134 -0
- package/src/storage/cacheBust.ts +26 -0
- package/src/storage/image.ts +8 -3
- package/src/storage/index.ts +3 -0
- package/src/testing/suites/definitionStoreSuite.ts +45 -0
- package/src/testing/suites/orgStoreSuite.ts +17 -0
- 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
|
+
}
|
package/src/storage/image.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
/**
|
package/src/storage/index.ts
CHANGED
|
@@ -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
|
}
|