@mieweb/pulsevault 0.0.2 → 0.1.1
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/CHANGELOG.md +258 -0
- package/LICENSE +15 -39
- package/OPERATIONS.md +223 -0
- package/PROTOCOL.md +345 -0
- package/README.md +548 -122
- package/augment.d.ts +1 -0
- package/augment.js +3 -0
- package/core.d.ts +1 -0
- package/core.js +7 -0
- package/dist/app.d.ts +91 -34
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +23 -33
- package/dist/app.js.map +1 -1
- package/dist/augment.d.ts +14 -7
- package/dist/augment.d.ts.map +1 -1
- package/dist/augment.js.map +1 -1
- package/dist/core.d.ts +108 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +494 -0
- package/dist/core.js.map +1 -0
- package/dist/lib/authorize.d.ts +19 -0
- package/dist/lib/authorize.d.ts.map +1 -0
- package/dist/lib/authorize.js +2 -0
- package/dist/lib/authorize.js.map +1 -0
- package/dist/lib/capability-token.d.ts +80 -0
- package/dist/lib/capability-token.d.ts.map +1 -0
- package/dist/lib/capability-token.js +134 -0
- package/dist/lib/capability-token.js.map +1 -0
- package/dist/lib/checksum.d.ts +45 -0
- package/dist/lib/checksum.d.ts.map +1 -0
- package/dist/lib/checksum.js +105 -0
- package/dist/lib/checksum.js.map +1 -0
- package/dist/lib/deeplinks.d.ts +37 -21
- package/dist/lib/deeplinks.d.ts.map +1 -1
- package/dist/lib/deeplinks.js +54 -13
- package/dist/lib/deeplinks.js.map +1 -1
- package/dist/lib/errors.d.ts +7 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +18 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/magic.d.ts +30 -5
- package/dist/lib/magic.d.ts.map +1 -1
- package/dist/lib/magic.js +51 -14
- package/dist/lib/magic.js.map +1 -1
- package/dist/lib/options.d.ts +41 -0
- package/dist/lib/options.d.ts.map +1 -0
- package/dist/lib/options.js +111 -0
- package/dist/lib/options.js.map +1 -0
- package/dist/lib/pulsevaultTus.d.ts +53 -26
- package/dist/lib/pulsevaultTus.d.ts.map +1 -1
- package/dist/lib/pulsevaultTus.js +174 -55
- package/dist/lib/pulsevaultTus.js.map +1 -1
- package/dist/lib/request.d.ts +26 -0
- package/dist/lib/request.d.ts.map +1 -0
- package/dist/lib/request.js +22 -0
- package/dist/lib/request.js.map +1 -0
- package/dist/lib/uuid.d.ts.map +1 -1
- package/dist/lib/uuid.js +1 -1
- package/dist/lib/uuid.js.map +1 -1
- package/dist/routes/pulsevault.d.ts +18 -16
- package/dist/routes/pulsevault.d.ts.map +1 -1
- package/dist/routes/pulsevault.js +121 -251
- package/dist/routes/pulsevault.js.map +1 -1
- package/dist/storage/local.d.ts +31 -10
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +205 -84
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/s3.d.ts +109 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +438 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage/types.d.ts +80 -17
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js +13 -1
- package/dist/storage/types.js.map +1 -1
- package/package.json +29 -7
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const DEFAULT_EXPIRY_SECONDS = 1800; // 30 minutes — long enough for one upload session.
|
|
3
|
+
const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
|
|
4
|
+
function base64urlEncode(input) {
|
|
5
|
+
return Buffer.from(input, 'utf8').toString('base64url');
|
|
6
|
+
}
|
|
7
|
+
function sign(payload, secret) {
|
|
8
|
+
return createHmac('sha256', secret).update(payload).digest('base64url');
|
|
9
|
+
}
|
|
10
|
+
/** Constant-time string comparison, tolerant of mismatched lengths (returns `false` rather than throwing). */
|
|
11
|
+
function timingSafeEqualStrings(a, b) {
|
|
12
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
13
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
14
|
+
if (bufA.length !== bufB.length)
|
|
15
|
+
return false;
|
|
16
|
+
return timingSafeEqual(bufA, bufB);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Mint a stateless, HMAC-signed capability token scoped to one artifact (or
|
|
20
|
+
* session-anchor artifact). No server-side session table — verification is a
|
|
21
|
+
* pure function of `(token, secret)`, so the issuing deployment can rotate
|
|
22
|
+
* keys, change its TTL policy, or revoke at the artifact level entirely on
|
|
23
|
+
* its own schedule. See `createCapabilityAuthorize` to use this as the
|
|
24
|
+
* plugin's `authorize` hook directly.
|
|
25
|
+
*/
|
|
26
|
+
export function issueCapabilityToken(artifactId, secret, opts) {
|
|
27
|
+
const now = Math.floor(Date.now() / 1000);
|
|
28
|
+
const claims = {
|
|
29
|
+
artifactId,
|
|
30
|
+
iat: now,
|
|
31
|
+
exp: now + (opts.expirySeconds ?? DEFAULT_EXPIRY_SECONDS),
|
|
32
|
+
kid: opts.keyId,
|
|
33
|
+
issuer: opts.issuer,
|
|
34
|
+
};
|
|
35
|
+
const payload = base64urlEncode(JSON.stringify(claims));
|
|
36
|
+
return `${payload}.${sign(payload, secret)}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Verify a token minted by `issueCapabilityToken`. Returns the authorized
|
|
40
|
+
* `artifactId` on success, or `null` for any failure (malformed token,
|
|
41
|
+
* unknown `kid`, bad signature, expired, issued too far in the future, or
|
|
42
|
+
* issuer mismatch) — deliberately collapsed to one failure shape so callers
|
|
43
|
+
* can't accidentally branch on *why* a token failed and leak which part was
|
|
44
|
+
* wrong to an attacker.
|
|
45
|
+
*/
|
|
46
|
+
export function verifyCapabilityToken(token, lookupSecret, opts) {
|
|
47
|
+
const dot = token.indexOf('.');
|
|
48
|
+
if (dot < 0)
|
|
49
|
+
return null;
|
|
50
|
+
const payload = token.slice(0, dot);
|
|
51
|
+
const signature = token.slice(dot + 1);
|
|
52
|
+
if (!payload || !signature)
|
|
53
|
+
return null;
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
// Valid JSON that isn't an object (e.g. the literal `null`, a number, a
|
|
62
|
+
// string) must fail like every other malformed token — return null rather
|
|
63
|
+
// than throw on the property access below.
|
|
64
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
65
|
+
return null;
|
|
66
|
+
const claims = parsed;
|
|
67
|
+
if (typeof claims.artifactId !== 'string' ||
|
|
68
|
+
typeof claims.iat !== 'number' ||
|
|
69
|
+
typeof claims.exp !== 'number' ||
|
|
70
|
+
typeof claims.kid !== 'string' ||
|
|
71
|
+
typeof claims.issuer !== 'string') {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const secret = lookupSecret(claims.kid);
|
|
75
|
+
if (!secret)
|
|
76
|
+
return null;
|
|
77
|
+
if (!timingSafeEqualStrings(signature, sign(payload, secret)))
|
|
78
|
+
return null;
|
|
79
|
+
const tolerance = opts.clockToleranceSeconds ?? DEFAULT_CLOCK_TOLERANCE_SECONDS;
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
if (claims.iat > now + tolerance)
|
|
82
|
+
return null;
|
|
83
|
+
if (claims.exp < now - tolerance)
|
|
84
|
+
return null;
|
|
85
|
+
if (claims.issuer !== opts.issuer)
|
|
86
|
+
return null;
|
|
87
|
+
return { artifactId: claims.artifactId };
|
|
88
|
+
}
|
|
89
|
+
/** Pull a bearer token from the `Authorization` header, falling back to `ctx.token` (the `resolve`-phase query-string forward). */
|
|
90
|
+
function extractToken(request, ctx) {
|
|
91
|
+
const header = request.headers.authorization;
|
|
92
|
+
// Match the auth scheme case-insensitively per RFC 7235 (clients may send
|
|
93
|
+
// `bearer `/`BEARER `); the prefix is fixed-length so the slice is unchanged.
|
|
94
|
+
if (typeof header === 'string' && /^Bearer /i.test(header)) {
|
|
95
|
+
return header.slice('Bearer '.length);
|
|
96
|
+
}
|
|
97
|
+
return ctx.token;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Ready-made `authorize` hook backed by `verifyCapabilityToken`. Pass this
|
|
101
|
+
* directly as the plugin's `authorize` option for a secure-by-default setup
|
|
102
|
+
* with no custom auth code:
|
|
103
|
+
*
|
|
104
|
+
* ```ts
|
|
105
|
+
* await app.register(pulseVault, {
|
|
106
|
+
* // ...
|
|
107
|
+
* authorize: createCapabilityAuthorize((kid) => keys[kid] ?? null, {
|
|
108
|
+
* issuer: "https://vault.acme-hospital.org",
|
|
109
|
+
* }),
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* Authorizes the request if the token's `artifactId` matches either the
|
|
114
|
+
* artifact being acted on, or that artifact's `relatedTo` session anchor —
|
|
115
|
+
* so one token issued for a video also covers its captions/manifest/thumbnail
|
|
116
|
+
* and (under `uploadUnit: "segment"`) every clip and the ordering manifest
|
|
117
|
+
* uploaded in the same session, without minting a token per artifact.
|
|
118
|
+
*/
|
|
119
|
+
export function createCapabilityAuthorize(lookupSecret, opts) {
|
|
120
|
+
return async (request, ctx) => {
|
|
121
|
+
const token = extractToken(request, ctx);
|
|
122
|
+
if (!token) {
|
|
123
|
+
throw Object.assign(new Error('Missing capability token'), { statusCode: 401 });
|
|
124
|
+
}
|
|
125
|
+
const verified = verifyCapabilityToken(token, lookupSecret, opts);
|
|
126
|
+
if (!verified) {
|
|
127
|
+
throw Object.assign(new Error('Invalid or expired capability token'), { statusCode: 403 });
|
|
128
|
+
}
|
|
129
|
+
if (ctx.artifactId !== verified.artifactId && ctx.relatedTo !== verified.artifactId) {
|
|
130
|
+
throw Object.assign(new Error('Token does not authorize this artifact'), { statusCode: 403 });
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=capability-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capability-token.js","sourceRoot":"","sources":["../../src/lib/capability-token.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { S3Storage } from '../storage/s3.js';
|
|
2
|
+
import type { PulseVaultValidatePayload } from './magic.js';
|
|
3
|
+
export type ChecksumAlgorithm = 'sha256' | 'sha1' | 'md5';
|
|
4
|
+
export type ParsedChecksum = {
|
|
5
|
+
algorithm: ChecksumAlgorithm;
|
|
6
|
+
digest: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Parse the client-supplied `Upload-Metadata.checksum` value, format
|
|
10
|
+
* `<algorithm>:<hex digest>` (e.g. `sha256:9f86d0...`). Returns `null` for a
|
|
11
|
+
* missing or malformed value — checksum verification is opt-in per upload,
|
|
12
|
+
* not required, unless the caller treats a `null` parse as a rejection.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseChecksumMetadata(raw: string | undefined | null): ParsedChecksum | null;
|
|
15
|
+
/**
|
|
16
|
+
* Build a `validatePayload` (or `validateCaptionsPayload`/`validateProjectPayload`)
|
|
17
|
+
* hook that verifies a client-supplied checksum against the finalized bytes
|
|
18
|
+
* before the upload is marked ready — for adapters that expose `localPath`
|
|
19
|
+
* (the built-in local adapter does; see `createS3ChecksumValidator` for S3/R2).
|
|
20
|
+
*
|
|
21
|
+
* This is at-rest corruption/tampering detection on the *finished* file, not
|
|
22
|
+
* a per-chunk integrity check — the TUS protocol's Checksum extension would
|
|
23
|
+
* cover chunks in flight, but the `@tus/server` version in use doesn't
|
|
24
|
+
* implement it. This fills the same gap for the finished artifact instead.
|
|
25
|
+
*
|
|
26
|
+
* Uploads with no `checksum` metadata are passed through unchanged (the
|
|
27
|
+
* client opted out); a checksum that's present but doesn't match is rejected
|
|
28
|
+
* with 422 and the bytes are removed, same semantics as `createMp4Sniffer`.
|
|
29
|
+
* Chain with another validator (e.g. `createMp4Sniffer`) via `next`:
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* validatePayload: createChecksumValidator(createMp4Sniffer(storage)),
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function createChecksumValidator(next?: PulseVaultValidatePayload): PulseVaultValidatePayload;
|
|
36
|
+
/**
|
|
37
|
+
* `createChecksumValidator` for the S3/R2 backend — streams the whole
|
|
38
|
+
* finalized object through a hash digest via `S3Storage.digestAll`, since S3
|
|
39
|
+
* has no local path to hash directly. Only worth using when checksum
|
|
40
|
+
* verification is explicitly wanted; it's a full extra download per upload,
|
|
41
|
+
* but (unlike buffering the object via `readAll`) never holds the whole
|
|
42
|
+
* object in memory at once.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createS3ChecksumValidator(storage: S3Storage, next?: PulseVaultValidatePayload): PulseVaultValidatePayload;
|
|
45
|
+
//# sourceMappingURL=checksum.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checksum.d.ts","sourceRoot":"","sources":["../../src/lib/checksum.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAC;AAE5D,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC;AAG1D,MAAM,MAAM,cAAc,GAAG;IAAE,SAAS,EAAE,iBAAiB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9E;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,cAAc,GAAG,IAAI,CAS3F;AAsBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,CAAC,EAAE,yBAAyB,GAC/B,yBAAyB,CAwB3B;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,SAAS,EAClB,IAAI,CAAC,EAAE,yBAAyB,GAC/B,yBAAyB,CAmB3B"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { pipeline } from 'node:stream/promises';
|
|
4
|
+
const SUPPORTED_ALGORITHMS = ['sha256', 'sha1', 'md5'];
|
|
5
|
+
/**
|
|
6
|
+
* Parse the client-supplied `Upload-Metadata.checksum` value, format
|
|
7
|
+
* `<algorithm>:<hex digest>` (e.g. `sha256:9f86d0...`). Returns `null` for a
|
|
8
|
+
* missing or malformed value — checksum verification is opt-in per upload,
|
|
9
|
+
* not required, unless the caller treats a `null` parse as a rejection.
|
|
10
|
+
*/
|
|
11
|
+
export function parseChecksumMetadata(raw) {
|
|
12
|
+
if (!raw)
|
|
13
|
+
return null;
|
|
14
|
+
const sep = raw.indexOf(':');
|
|
15
|
+
if (sep < 0)
|
|
16
|
+
return null;
|
|
17
|
+
const algorithm = raw.slice(0, sep).toLowerCase();
|
|
18
|
+
const digest = raw.slice(sep + 1).toLowerCase();
|
|
19
|
+
if (!isSupportedAlgorithm(algorithm))
|
|
20
|
+
return null;
|
|
21
|
+
if (!/^[0-9a-f]+$/.test(digest) || digest.length === 0)
|
|
22
|
+
return null;
|
|
23
|
+
return { algorithm, digest };
|
|
24
|
+
}
|
|
25
|
+
function isSupportedAlgorithm(value) {
|
|
26
|
+
return SUPPORTED_ALGORITHMS.includes(value);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Hashes the file at `path` via a stream instead of `fs.readFile` so a
|
|
30
|
+
* multi-gigabyte upload (`maxUploadSize: Infinity` is a supported
|
|
31
|
+
* configuration) never has to be held in memory as a single `Buffer` just to
|
|
32
|
+
* compute its checksum.
|
|
33
|
+
*/
|
|
34
|
+
async function digestLocalFile(path, algorithm) {
|
|
35
|
+
const hash = createHash(algorithm);
|
|
36
|
+
await pipeline(createReadStream(path), hash);
|
|
37
|
+
return hash.digest('hex');
|
|
38
|
+
}
|
|
39
|
+
function checksumError(message) {
|
|
40
|
+
return Object.assign(new Error(message), { statusCode: 422 });
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a `validatePayload` (or `validateCaptionsPayload`/`validateProjectPayload`)
|
|
44
|
+
* hook that verifies a client-supplied checksum against the finalized bytes
|
|
45
|
+
* before the upload is marked ready — for adapters that expose `localPath`
|
|
46
|
+
* (the built-in local adapter does; see `createS3ChecksumValidator` for S3/R2).
|
|
47
|
+
*
|
|
48
|
+
* This is at-rest corruption/tampering detection on the *finished* file, not
|
|
49
|
+
* a per-chunk integrity check — the TUS protocol's Checksum extension would
|
|
50
|
+
* cover chunks in flight, but the `@tus/server` version in use doesn't
|
|
51
|
+
* implement it. This fills the same gap for the finished artifact instead.
|
|
52
|
+
*
|
|
53
|
+
* Uploads with no `checksum` metadata are passed through unchanged (the
|
|
54
|
+
* client opted out); a checksum that's present but doesn't match is rejected
|
|
55
|
+
* with 422 and the bytes are removed, same semantics as `createMp4Sniffer`.
|
|
56
|
+
* Chain with another validator (e.g. `createMp4Sniffer`) via `next`:
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* validatePayload: createChecksumValidator(createMp4Sniffer(storage)),
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function createChecksumValidator(next) {
|
|
63
|
+
return async (request, ctx) => {
|
|
64
|
+
const checksum = parseChecksumMetadata(ctx.checksum);
|
|
65
|
+
if (checksum) {
|
|
66
|
+
if (!ctx.localPath) {
|
|
67
|
+
// Wrong validator wired for this adapter — a server misconfiguration,
|
|
68
|
+
// not a bad client payload — so surface 500, not 422 (which would tell
|
|
69
|
+
// the client their file was rejected).
|
|
70
|
+
throw Object.assign(new Error('Checksum verification requested but this storage adapter has no local path — use createS3ChecksumValidator for S3/R2 storage'), { statusCode: 500 });
|
|
71
|
+
}
|
|
72
|
+
const actual = await digestLocalFile(ctx.localPath, checksum.algorithm);
|
|
73
|
+
if (actual !== checksum.digest) {
|
|
74
|
+
throw checksumError(`Checksum mismatch: expected ${checksum.algorithm}:${checksum.digest}, got ${checksum.algorithm}:${actual}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (next)
|
|
78
|
+
await next(request, ctx);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `createChecksumValidator` for the S3/R2 backend — streams the whole
|
|
83
|
+
* finalized object through a hash digest via `S3Storage.digestAll`, since S3
|
|
84
|
+
* has no local path to hash directly. Only worth using when checksum
|
|
85
|
+
* verification is explicitly wanted; it's a full extra download per upload,
|
|
86
|
+
* but (unlike buffering the object via `readAll`) never holds the whole
|
|
87
|
+
* object in memory at once.
|
|
88
|
+
*/
|
|
89
|
+
export function createS3ChecksumValidator(storage, next) {
|
|
90
|
+
return async (request, ctx) => {
|
|
91
|
+
const checksum = parseChecksumMetadata(ctx.checksum);
|
|
92
|
+
if (checksum) {
|
|
93
|
+
const actual = await storage.digestAll(ctx.artifactId, checksum.algorithm);
|
|
94
|
+
if (actual === null) {
|
|
95
|
+
throw Object.assign(new Error(`Cannot validate upload ${ctx.artifactId}: no object bytes available`), { statusCode: 500 });
|
|
96
|
+
}
|
|
97
|
+
if (actual !== checksum.digest) {
|
|
98
|
+
throw checksumError(`Checksum mismatch: expected ${checksum.algorithm}:${checksum.digest}, got ${checksum.algorithm}:${actual}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (next)
|
|
102
|
+
await next(request, ctx);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=checksum.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checksum.js","sourceRoot":"","sources":["../../src/lib/checksum.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/deeplinks.d.ts
CHANGED
|
@@ -1,32 +1,48 @@
|
|
|
1
|
-
export type ConfigureDestinationLinkOptions = {
|
|
2
|
-
/** Full origin of the PulseVault server, e.g. `https://example.com` or `http://192.168.1.10:3030`. */
|
|
3
|
-
server: string;
|
|
4
|
-
/** Opaque token forwarded to the server's `authorize` hook. Omit for unauthenticated servers. */
|
|
5
|
-
token?: string;
|
|
6
|
-
/** Human-readable name shown in the Pulse app's destination list. Defaults to the server hostname. */
|
|
7
|
-
name?: string;
|
|
8
|
-
};
|
|
9
1
|
export type UploadLinkOptions = {
|
|
10
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Full base URL the client should upload to — origin *plus* whatever
|
|
4
|
+
* `prefix` you registered the plugin under (e.g.
|
|
5
|
+
* `https://vault.example.org/pulsevault`, not just `https://vault.example.org`).
|
|
6
|
+
* The client builds every request as `${server}/<path>` with no separate
|
|
7
|
+
* prefix concept of its own. Must be `https://` (or `http://localhost`/a
|
|
8
|
+
* private IP for local dev).
|
|
9
|
+
*/
|
|
11
10
|
server: string;
|
|
12
|
-
/** Opaque token forwarded to the server's `authorize` hook
|
|
11
|
+
/** Opaque token forwarded to the server's `authorize` hook — typically minted with `issueCapabilityToken`. Omit for unauthenticated servers. */
|
|
13
12
|
token?: string;
|
|
14
13
|
/**
|
|
15
|
-
* Server-side
|
|
16
|
-
* `
|
|
17
|
-
* Generate with `crypto.randomUUID()`.
|
|
14
|
+
* Server-side artifact UUID. Used as both the app's local draft key and the
|
|
15
|
+
* `artifactId` in `Upload-Metadata`, so the app skips a separate reserve
|
|
16
|
+
* step. Generate with `crypto.randomUUID()`.
|
|
18
17
|
*/
|
|
19
|
-
|
|
18
|
+
artifactId: string;
|
|
19
|
+
/**
|
|
20
|
+
* Per-session override of the deployment-wide `uploadUnit` advertised via
|
|
21
|
+
* `GET /capabilities` (PROTOCOL.md §8). Omit to let the client fall back to
|
|
22
|
+
* whatever `/capabilities` reports — the same behavior as before this field
|
|
23
|
+
* existed. Set this when an operator wants "segment" and "merged" sessions
|
|
24
|
+
* live at the same time (e.g. different pairing flows, a/b testing, a
|
|
25
|
+
* gradual rollout) rather than one fixed value for the whole deployment:
|
|
26
|
+
* `/capabilities` can only ever report one current value, and a client
|
|
27
|
+
* that reads it separately from opening the link is racing whatever the
|
|
28
|
+
* server was serving at that moment, not the value this specific session
|
|
29
|
+
* was paired under.
|
|
30
|
+
*/
|
|
31
|
+
uploadUnit?: 'segment' | 'merged';
|
|
20
32
|
};
|
|
21
|
-
/**
|
|
22
|
-
* Build a `pulsecam://` deep link that adds this server as a saved upload
|
|
23
|
-
* destination in the Pulse app. No server request is made when the link is
|
|
24
|
-
* opened — the app stores the config locally.
|
|
25
|
-
*/
|
|
26
|
-
export declare function buildConfigureDestinationLink(opts: ConfigureDestinationLinkOptions): string;
|
|
27
33
|
/**
|
|
28
34
|
* Build a `pulsecam://` deep link that opens the Pulse app directly on the
|
|
29
|
-
* upload screen for a specific
|
|
35
|
+
* upload screen for a specific artifact, pointed at this server.
|
|
36
|
+
*
|
|
37
|
+
* No `mode` param — there's only one mode, so it was always redundant. `v`
|
|
38
|
+
* lets the app refuse/explain a future incompatible link shape instead of
|
|
39
|
+
* misparsing it.
|
|
40
|
+
*
|
|
41
|
+
* Throws if `server` isn't `https://` (or the narrow localhost/private-IP dev
|
|
42
|
+
* exception PROTOCOL.md §3 carves out) — failing fast here, at the one place
|
|
43
|
+
* that constructs the link, is more reliable than relying on every client to
|
|
44
|
+
* independently reject a plaintext origin the operator never should have
|
|
45
|
+
* issued in the first place.
|
|
30
46
|
*/
|
|
31
47
|
export declare function buildUploadLink(opts: UploadLinkOptions): string;
|
|
32
48
|
//# sourceMappingURL=deeplinks.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deeplinks.d.ts","sourceRoot":"","sources":["../../src/lib/deeplinks.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM
|
|
1
|
+
{"version":3,"file":"deeplinks.d.ts","sourceRoot":"","sources":["../../src/lib/deeplinks.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,gJAAgJ;IAChJ,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;CACnC,CAAC;AA4BF;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA0B/D"}
|
package/dist/lib/deeplinks.js
CHANGED
|
@@ -1,24 +1,65 @@
|
|
|
1
|
+
/** Deep-link wire format version. Bump when the param shape changes incompatibly. */
|
|
2
|
+
const LINK_VERSION = '1';
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
4
|
+
* Private/dev origins allowed over plain http — mirrors the Pulse client's
|
|
5
|
+
* own `isPrivateDevOrigin` check (PROTOCOL.md §3) exactly, so a link this
|
|
6
|
+
* helper is willing to build is also one the client is willing to accept.
|
|
7
|
+
* Never extended beyond non-globally-routable address space (RFC 1918
|
|
8
|
+
* private ranges, RFC 6598 carrier-grade NAT, link-local) plus
|
|
9
|
+
* loopback/localhost — never a public IP or domain just because it "looks
|
|
10
|
+
* internal."
|
|
5
11
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
function isPrivateDevOrigin(url) {
|
|
13
|
+
if (url.protocol !== 'http:')
|
|
14
|
+
return false;
|
|
15
|
+
const host = url.hostname;
|
|
16
|
+
return (host === 'localhost' ||
|
|
17
|
+
host === '127.0.0.1' ||
|
|
18
|
+
/^10\.\d+\.\d+\.\d+$/.test(host) ||
|
|
19
|
+
/^192\.168\.\d+\.\d+$/.test(host) ||
|
|
20
|
+
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/.test(host) ||
|
|
21
|
+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/.test(host) || // RFC 6598, 100.64.0.0/10
|
|
22
|
+
/^169\.254\.\d+\.\d+$/.test(host) // link-local
|
|
23
|
+
);
|
|
13
24
|
}
|
|
14
25
|
/**
|
|
15
26
|
* Build a `pulsecam://` deep link that opens the Pulse app directly on the
|
|
16
|
-
* upload screen for a specific
|
|
27
|
+
* upload screen for a specific artifact, pointed at this server.
|
|
28
|
+
*
|
|
29
|
+
* No `mode` param — there's only one mode, so it was always redundant. `v`
|
|
30
|
+
* lets the app refuse/explain a future incompatible link shape instead of
|
|
31
|
+
* misparsing it.
|
|
32
|
+
*
|
|
33
|
+
* Throws if `server` isn't `https://` (or the narrow localhost/private-IP dev
|
|
34
|
+
* exception PROTOCOL.md §3 carves out) — failing fast here, at the one place
|
|
35
|
+
* that constructs the link, is more reliable than relying on every client to
|
|
36
|
+
* independently reject a plaintext origin the operator never should have
|
|
37
|
+
* issued in the first place.
|
|
17
38
|
*/
|
|
18
39
|
export function buildUploadLink(opts) {
|
|
19
|
-
|
|
40
|
+
let server;
|
|
41
|
+
try {
|
|
42
|
+
server = new URL(opts.server);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error(`buildUploadLink: \`server\` is not a valid URL: ${opts.server}`);
|
|
46
|
+
}
|
|
47
|
+
if (server.protocol !== 'https:' && !isPrivateDevOrigin(server)) {
|
|
48
|
+
throw new Error(`buildUploadLink: \`server\` must be https:// (got "${opts.server}") — ` +
|
|
49
|
+
'the only exception is http://localhost or a private IP literal for local development.');
|
|
50
|
+
}
|
|
51
|
+
if (opts.uploadUnit !== undefined && opts.uploadUnit !== 'segment' && opts.uploadUnit !== 'merged') {
|
|
52
|
+
throw new Error(`buildUploadLink: \`uploadUnit\` must be "segment" or "merged" (got "${opts.uploadUnit}")`);
|
|
53
|
+
}
|
|
54
|
+
const params = new URLSearchParams({
|
|
55
|
+
v: LINK_VERSION,
|
|
56
|
+
artifactId: opts.artifactId,
|
|
57
|
+
server: opts.server,
|
|
58
|
+
});
|
|
20
59
|
if (opts.token)
|
|
21
|
-
params.set(
|
|
60
|
+
params.set('token', opts.token);
|
|
61
|
+
if (opts.uploadUnit)
|
|
62
|
+
params.set('uploadUnit', opts.uploadUnit);
|
|
22
63
|
return `pulsecam://?${params.toString()}`;
|
|
23
64
|
}
|
|
24
65
|
//# sourceMappingURL=deeplinks.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deeplinks.js","sourceRoot":"","sources":["../../src/lib/deeplinks.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"deeplinks.js","sourceRoot":"","sources":["../../src/lib/deeplinks.ts"],"names":[],"mappings":"AAiCA,qFAAqF;AACrF,MAAM,YAAY,GAAG,GAAG,CAAC;AAEzB;;;;;;;;GAQG;AACH,SAAS,kBAAkB,CAAC,GAAQ;IAClC,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO,KAAK,CAAC;IAC3C,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC;IAC1B,OAAO,CACL,IAAI,KAAK,WAAW;QACpB,IAAI,KAAK,WAAW;QACpB,qBAAqB,CAAC,IAAI,CAAC,IAAI,CAAC;QAChC,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC;QACjC,qCAAqC,CAAC,IAAI,CAAC,IAAI,CAAC;QAChD,mDAAmD,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,0BAA0B;QAC5F,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,aAAa;KAChD,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,IAAuB;IACrD,IAAI,MAAW,CAAC;IAChB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,mDAAmD,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;IACpF,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAC;QAChE,MAAM,IAAI,KAAK,CACb,sDAAsD,IAAI,CAAC,MAAM,OAAO;YACtE,uFAAuF,CAC1F,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS,IAAI,IAAI,CAAC,UAAU,KAAK,SAAS,IAAI,IAAI,CAAC,UAAU,KAAK,QAAQ,EAAE,CAAC;QACnG,MAAM,IAAI,KAAK,CAAC,uEAAuE,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC;IAC9G,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC;QACjC,CAAC,EAAE,YAAY;QACf,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,MAAM,EAAE,IAAI,CAAC,MAAM;KACpB,CAAC,CAAC;IACH,IAAI,IAAI,CAAC,KAAK;QAAE,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IAChD,IAAI,IAAI,CAAC,UAAU;QAAE,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;IAC/D,OAAO,eAAe,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC;AAC5C,CAAC"}
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -3,4 +3,11 @@ export type PulseVaultErrorBody = {
|
|
|
3
3
|
error: string;
|
|
4
4
|
};
|
|
5
5
|
export declare function pulseVaultError(error: string): PulseVaultErrorBody;
|
|
6
|
+
/**
|
|
7
|
+
* Extract a numeric HTTP status from a thrown error, honoring both `statusCode`
|
|
8
|
+
* (Fastify convention) and `status_code` (tus convention). Returns `fallback`
|
|
9
|
+
* when neither is a *valid* HTTP status — a consumer hook throwing
|
|
10
|
+
* `{ statusCode: 42 }` must degrade to the fallback, not crash `writeHead`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function statusCodeOf(err: unknown, fallback: number): number;
|
|
6
13
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/lib/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,mBAAmB,GAAG;IAChC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,mBAAmB,CAElE"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,mBAAmB,GAAG;IAChC,EAAE,EAAE,KAAK,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,mBAAmB,CAElE;AAOD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKnE"}
|
package/dist/lib/errors.js
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
export function pulseVaultError(error) {
|
|
2
2
|
return { ok: false, error };
|
|
3
3
|
}
|
|
4
|
+
/** A usable HTTP status: an integer in 100–599. Anything else would make `res.writeHead` throw. */
|
|
5
|
+
function isHttpStatus(value) {
|
|
6
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Extract a numeric HTTP status from a thrown error, honoring both `statusCode`
|
|
10
|
+
* (Fastify convention) and `status_code` (tus convention). Returns `fallback`
|
|
11
|
+
* when neither is a *valid* HTTP status — a consumer hook throwing
|
|
12
|
+
* `{ statusCode: 42 }` must degrade to the fallback, not crash `writeHead`.
|
|
13
|
+
*/
|
|
14
|
+
export function statusCodeOf(err, fallback) {
|
|
15
|
+
const e = err;
|
|
16
|
+
if (isHttpStatus(e?.statusCode))
|
|
17
|
+
return e.statusCode;
|
|
18
|
+
if (isHttpStatus(e?.status_code))
|
|
19
|
+
return e.status_code;
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
4
22
|
//# sourceMappingURL=errors.js.map
|
package/dist/lib/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAKA,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAC9B,CAAC"}
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAKA,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAC9B,CAAC;AAED,mGAAmG;AACnG,SAAS,YAAY,CAAC,KAAc;IAClC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI,GAAG,CAAC;AAC9F,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,GAAY,EAAE,QAAgB;IACzD,MAAM,CAAC,GAAG,GAAsD,CAAC;IACjE,IAAI,YAAY,CAAC,CAAC,EAAE,UAAU,CAAC;QAAE,OAAO,CAAC,CAAC,UAAU,CAAC;IACrD,IAAI,YAAY,CAAC,CAAC,EAAE,WAAW,CAAC;QAAE,OAAO,CAAC,CAAC,WAAW,CAAC;IACvD,OAAO,QAAQ,CAAC;AAClB,CAAC"}
|
package/dist/lib/magic.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { LocalStorage } from
|
|
1
|
+
import type { PulseVaultRequest } from './request.js';
|
|
2
|
+
import type { LocalStorage } from '../storage/local.js';
|
|
3
|
+
import type { S3Storage } from '../storage/s3.js';
|
|
4
|
+
import type { UploadKind } from '../storage/types.js';
|
|
3
5
|
/**
|
|
4
6
|
* Optional plugin-level hook: after TUS writes the final byte but before the
|
|
5
7
|
* upload is marked ready (or the consumer's `onUploadComplete` runs),
|
|
6
8
|
* validate the payload bytes. Throw to reject — the plugin translates the
|
|
7
|
-
* throw into a 422 response, calls `storage.remove?.(
|
|
9
|
+
* throw into a 422 response, calls `storage.remove?.(artifactId)` to free the
|
|
8
10
|
* disk, and never flips the sidecar to `"ready"`.
|
|
9
11
|
*
|
|
10
12
|
* The `localPath` field is populated for adapters that expose
|
|
@@ -12,12 +14,16 @@ import type { LocalStorage } from "../storage/local.js";
|
|
|
12
14
|
* bytes don't live on local disk (S3, etc.), `localPath` is `null` and the
|
|
13
15
|
* validator has to fetch bytes through whatever API the adapter provides.
|
|
14
16
|
*/
|
|
15
|
-
export type PulseVaultValidatePayload = (request:
|
|
16
|
-
|
|
17
|
+
export type PulseVaultValidatePayload = (request: PulseVaultRequest, ctx: {
|
|
18
|
+
artifactId: string;
|
|
17
19
|
size: number;
|
|
18
20
|
uploadId: string;
|
|
21
|
+
/** Which artifact kind this upload is. Runs for every kind — branch on this if you only want a check applied to one. */
|
|
22
|
+
kind: UploadKind;
|
|
19
23
|
/** Absolute local path to the finalized bytes, or `null` if unavailable. */
|
|
20
24
|
localPath: string | null;
|
|
25
|
+
/** Client-supplied `<algorithm>:<hex>` digest, if `Upload-Metadata.checksum` was sent. */
|
|
26
|
+
checksum?: string;
|
|
21
27
|
}) => void | Promise<void>;
|
|
22
28
|
/**
|
|
23
29
|
* Check whether a file's first bytes match the ISO base media file format
|
|
@@ -47,4 +53,23 @@ export declare function sniffMp4(filePath: string): Promise<boolean>;
|
|
|
47
53
|
* ```
|
|
48
54
|
*/
|
|
49
55
|
export declare function createMp4Sniffer(storage: LocalStorage): PulseVaultValidatePayload;
|
|
56
|
+
/**
|
|
57
|
+
* `createMp4Sniffer` for the S3 / R2 backend. Instead of opening a local file,
|
|
58
|
+
* it asks the adapter for the first 12 bytes of the finalized object via a
|
|
59
|
+
* small ranged GET (`S3Storage.readHeader`) and applies the same `ftyp` check.
|
|
60
|
+
* Runs in the same lifecycle slot — after `@tus/s3-store` completes the
|
|
61
|
+
* multipart upload (so the object is readable) but before `markReady` — so a
|
|
62
|
+
* non-MP4 upload is removed and never served.
|
|
63
|
+
*
|
|
64
|
+
* Usage:
|
|
65
|
+
* ```ts
|
|
66
|
+
* const storage = await createS3Storage({ ... });
|
|
67
|
+
* await app.register(pulseVault, {
|
|
68
|
+
* storage,
|
|
69
|
+
* validatePayload: createS3Mp4Sniffer(storage),
|
|
70
|
+
* // ...
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function createS3Mp4Sniffer(storage: S3Storage): PulseVaultValidatePayload;
|
|
50
75
|
//# sourceMappingURL=magic.d.ts.map
|
package/dist/lib/magic.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"magic.d.ts","sourceRoot":"","sources":["../../src/lib/magic.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"magic.d.ts","sourceRoot":"","sources":["../../src/lib/magic.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,yBAAyB,GAAG,CACtC,OAAO,EAAE,iBAAiB,EAC1B,GAAG,EAAE;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,wHAAwH;IACxH,IAAI,EAAE,UAAU,CAAC;IACjB,4EAA4E;IAC5E,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,KACE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAE1B;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAejE;AAaD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,YAAY,GAAG,yBAAyB,CAgBjF;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,GAAG,yBAAyB,CAehF"}
|