@drhomet/mithaq 0.0.0-stage → 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/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +231 -0
- package/dist/asas/build.js +787 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +145 -0
- package/dist/asas/cli/upgrade.d.ts +20 -0
- package/dist/asas/cli/upgrade.js +147 -0
- package/dist/asas/http/fetch.d.ts +23 -0
- package/dist/asas/http/fetch.js +115 -0
- package/dist/asas/http/orpc.d.ts +43 -0
- package/dist/asas/http/orpc.js +135 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +191 -0
- package/dist/asas/runner/errors.d.ts +60 -0
- package/dist/asas/runner/errors.js +59 -0
- package/dist/asas/runner/read.d.ts +68 -0
- package/dist/asas/runner/read.js +579 -0
- package/dist/asas/runner/runtime.d.ts +21 -0
- package/dist/asas/runner/runtime.js +8 -0
- package/dist/asas/runner/steps.d.ts +400 -0
- package/dist/asas/runner/steps.js +271 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +213 -0
- package/dist/asas/runner/write.d.ts +444 -0
- package/dist/asas/runner/write.js +791 -0
- package/dist/auth/better-auth/access.d.ts +15 -0
- package/dist/auth/better-auth/access.js +71 -0
- package/dist/auth/better-auth/generate.d.ts +29 -0
- package/dist/auth/better-auth/generate.js +310 -0
- package/dist/auth/better-auth/index.d.ts +92 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +18 -0
- package/dist/auth/better-auth/tenant.js +26 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +736 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +123 -0
- package/dist/base/fields.js +51 -0
- package/dist/base/index.d.ts +46 -0
- package/dist/base/index.js +5 -0
- package/dist/base/kinds.d.ts +54 -0
- package/dist/base/kinds.js +78 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +80 -0
- package/dist/drizzle/index.d.ts +438 -0
- package/dist/drizzle/index.js +832 -0
- package/dist/drizzle/kinds.d.ts +27 -0
- package/dist/drizzle/kinds.js +41 -0
- package/dist/drizzle/migrate.d.ts +133 -0
- package/dist/drizzle/migrate.js +1285 -0
- package/dist/drizzle/proxy/index.d.ts +1 -0
- package/dist/drizzle/proxy/index.js +1 -0
- package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
- package/dist/drizzle/proxy/node-postgres.js +1 -0
- package/dist/drizzle/proxy/pg-core.d.ts +1 -0
- package/dist/drizzle/proxy/pg-core.js +1 -0
- package/dist/drizzle/rules.d.ts +336 -0
- package/dist/drizzle/rules.js +44 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +36 -0
- package/dist/mithaq/define.d.ts +288 -0
- package/dist/mithaq/define.js +43 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +234 -0
- package/dist/mithaq/language.d.ts +335 -0
- package/dist/mithaq/language.js +76 -0
- package/dist/mithaq/lock.d.ts +71 -0
- package/dist/mithaq/lock.js +329 -0
- package/dist/mithaq/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +106 -0
- package/dist/mithaq/refusal.d.ts +6 -0
- package/dist/mithaq/refusal.js +6 -0
- package/dist/mithaq/resolve.d.ts +10 -0
- package/dist/mithaq/resolve.js +954 -0
- package/dist/mithaq/rows.d.ts +87 -0
- package/dist/mithaq/rows.js +174 -0
- package/dist/mithaq/rukn.d.ts +72 -0
- package/dist/mithaq/rukn.js +15 -0
- package/dist/mithaq/standard-schema.d.ts +38 -0
- package/dist/mithaq/standard-schema.js +2 -0
- package/dist/mithaq/types.d.ts +358 -0
- package/dist/mithaq/types.js +15 -0
- package/dist/mithaq/user.d.ts +19 -0
- package/dist/mithaq/user.js +16 -0
- package/dist/mithaq/where.d.ts +32 -0
- package/dist/mithaq/where.js +87 -0
- package/dist/rbac/index.d.ts +105 -0
- package/dist/rbac/index.js +182 -0
- package/dist/rbac/steps.d.ts +22 -0
- package/dist/rbac/steps.js +161 -0
- package/dist/rich-text/index.d.ts +27 -0
- package/dist/rich-text/index.js +58 -0
- package/dist/rich-text/rich-text.d.ts +159 -0
- package/dist/rich-text/rich-text.js +372 -0
- package/dist/screen/client.d.ts +153 -0
- package/dist/screen/client.js +68 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +226 -0
- package/dist/screen/index.d.ts +479 -0
- package/dist/screen/index.js +645 -0
- package/dist/screen/react.d.ts +95 -0
- package/dist/screen/react.js +58 -0
- package/dist/screen/studio.d.ts +24 -0
- package/dist/screen/studio.js +68 -0
- package/dist/service/index.d.ts +63 -0
- package/dist/service/index.js +15 -0
- package/dist/storage/allowed-type.d.ts +5 -0
- package/dist/storage/allowed-type.js +7 -0
- package/dist/storage/index.d.ts +43 -0
- package/dist/storage/index.js +44 -0
- package/dist/storage/storage.d.ts +384 -0
- package/dist/storage/storage.js +405 -0
- package/dist/versions/index.d.ts +37 -0
- package/dist/versions/index.js +8 -0
- package/dist/versions/versions.d.ts +104 -0
- package/dist/versions/versions.js +160 -0
- package/dist/zod/index.d.ts +184 -0
- package/dist/zod/index.js +121 -0
- package/dist/zod/kinds.d.ts +34 -0
- package/dist/zod/kinds.js +49 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- package/package.json +63 -4
- package/r/mithaq-cells.json +20 -0
- package/r/mithaq-controls.json +37 -0
- package/r/mithaq-directional-icon.json +15 -0
- package/r/mithaq-expected-updated-at.json +15 -0
- package/r/mithaq-field-shell.json +18 -0
- package/r/mithaq-filter-operators.json +15 -0
- package/r/mithaq-form.json +33 -0
- package/r/mithaq-list-columns.json +23 -0
- package/r/mithaq-list-filters.json +24 -0
- package/r/mithaq-list-pagination.json +24 -0
- package/r/mithaq-list-query.json +18 -0
- package/r/mithaq-list-selection.json +19 -0
- package/r/mithaq-list.json +33 -0
- package/r/mithaq-nav.json +24 -0
- package/r/mithaq-nodes.json +34 -0
- package/r/mithaq-relationship-cell.json +20 -0
- package/r/mithaq-relationship-control.json +20 -0
- package/r/mithaq-rich-text.json +40 -0
- package/r/mithaq-screens-host.json +96 -0
- package/r/mithaq-storage.json +44 -0
- package/r/mithaq-upload.json +29 -0
- package/r/registry.json +550 -0
- package/README.md +0 -3
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// The object key is decided before the upload and the row that names it is made
|
|
2
|
+
// in one insert after the upload is verified, so no stored file goes unnamed by a failed second write.
|
|
3
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
5
|
+
import { isStorage } from '../mithaq/language.js';
|
|
6
|
+
import { providerOf } from '../base/rules.js';
|
|
7
|
+
import { AsasError, codes, denied, invalid, missing } from '../asas/runner/errors.js';
|
|
8
|
+
import { sealOf } from '../asas/runner/steps.js';
|
|
9
|
+
import { allowedType } from './allowed-type.js';
|
|
10
|
+
/** Written by the runner when the row is made, from the signed upload, and by nobody after. */
|
|
11
|
+
const fileColumn = { create: 'application', update: 'never' };
|
|
12
|
+
/**
|
|
13
|
+
* The file columns a kayan that holds files spreads into its fields beside `defaultFields`.
|
|
14
|
+
* Filled from the verified upload and never changed: a file is replaced by a new row.
|
|
15
|
+
*/
|
|
16
|
+
export const uploadFields = {
|
|
17
|
+
filename: { kind: 'text', label: { en: 'File name', ar: 'اسم الملف' }, rules: { providedBy: fileColumn, required: true, index: true } },
|
|
18
|
+
mimeType: { kind: 'text', label: { en: 'File type', ar: 'نوع الملف' }, rules: { providedBy: fileColumn, required: true } },
|
|
19
|
+
filesize: { kind: 'number', label: { en: 'File size', ar: 'حجم الملف' }, rules: { providedBy: fileColumn, required: true } },
|
|
20
|
+
width: { kind: 'number', label: { en: 'Width', ar: 'العرض' }, rules: { providedBy: fileColumn } },
|
|
21
|
+
height: { kind: 'number', label: { en: 'Height', ar: 'الارتفاع' }, rules: { providedBy: fileColumn } },
|
|
22
|
+
key: { kind: 'text', label: { en: 'Object key', ar: 'مفتاح الملف' }, rules: { providedBy: fileColumn, required: true, unique: true } },
|
|
23
|
+
storage: { kind: 'text', label: { en: 'Storage', ar: 'التخزين' }, rules: { providedBy: fileColumn, required: true } },
|
|
24
|
+
};
|
|
25
|
+
const storageFunctions = {
|
|
26
|
+
key: 'defaultKey',
|
|
27
|
+
verify: 'defaultVerify',
|
|
28
|
+
disposition: 'defaultDisposition',
|
|
29
|
+
sweep: 'defaultSweep',
|
|
30
|
+
instructions: 'defaultUploadInstructions',
|
|
31
|
+
uploaded: 'defaultUploaded',
|
|
32
|
+
removed: 'defaultRemoveFile',
|
|
33
|
+
serve: 'defaultServeFile',
|
|
34
|
+
};
|
|
35
|
+
/** The `fileSize` the template names in its storage config: 10 MiB. */
|
|
36
|
+
export const defaultFileSize = 10 * 1024 * 1024;
|
|
37
|
+
const referenceSeconds = 60 * 60;
|
|
38
|
+
const svg = 'image/svg+xml';
|
|
39
|
+
/** The type check both halves make, in a module the browser imports too. */
|
|
40
|
+
export { allowedType };
|
|
41
|
+
/** The file name with every directory part and control character dropped, or an empty string. */
|
|
42
|
+
export const sanitizeFilename = (filename) => {
|
|
43
|
+
let sanitized = filename.replace(/\\/g, '/');
|
|
44
|
+
sanitized = sanitized.slice(sanitized.lastIndexOf('/') + 1);
|
|
45
|
+
if (sanitized === '.' || sanitized === '..')
|
|
46
|
+
return '';
|
|
47
|
+
// eslint-disable-next-line no-control-regex
|
|
48
|
+
return sanitized.replace(/[\x00-\x1f\x80-\x9f]/g, '');
|
|
49
|
+
};
|
|
50
|
+
const fields = ['id', 'key', 'kayan', 'tenant', 'filename', 'type', 'size', 'storage', 'exp'];
|
|
51
|
+
const signatureOf = (secret, reference) => createHmac('sha256', secret).update(JSON.stringify(fields.map((name) => reference[name]))).digest('base64url');
|
|
52
|
+
const storageOf = (kayan) => {
|
|
53
|
+
const { storage } = kayan;
|
|
54
|
+
if (!storage)
|
|
55
|
+
throw new Error(`Kayan "${kayan.name}" runs a storage step and writes no targets.storage.`);
|
|
56
|
+
return storage;
|
|
57
|
+
};
|
|
58
|
+
/** The adapter a row or a reference names. Read as an own key, so no name reaches an inherited property. */
|
|
59
|
+
export const adapterOf = ({ kayan, name }) => {
|
|
60
|
+
const { config } = storageOf(kayan);
|
|
61
|
+
const adapter = typeof name === 'string' && Object.hasOwn(config.adapters, name) ? config.adapters[name] : undefined;
|
|
62
|
+
if (!adapter)
|
|
63
|
+
throw new Error(`A stored file names adapter "${String(name)}", which is not one of storage.adapters.`);
|
|
64
|
+
return adapter;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Signs the reference an upload's create must name back: the row's id, the key, the kayan,
|
|
68
|
+
* the actor's tenant from its session, the file, the adapter and an expiry, under HMAC-SHA256.
|
|
69
|
+
*/
|
|
70
|
+
export const signReference = ({ kayan, actor, id, filename, type, size, }) => {
|
|
71
|
+
const { config, tenant: tenantOf } = storageOf(kayan);
|
|
72
|
+
const tenant = tenantOf(actor);
|
|
73
|
+
if (tenant === undefined) {
|
|
74
|
+
throw denied({ message: `Forbidden: "${kayan.name}" files belong to an organization, and this actor's session has no active organization it is a verified member of.`, key: 'error.forbidden.create', params: { kayan: kayan.name, operation: 'create' } });
|
|
75
|
+
}
|
|
76
|
+
const adapter = adapterOf({ kayan, name: config.adapter });
|
|
77
|
+
const key = config.key({ prefix: adapter.prefix, tenant, kayan: kayan.name, id, filename });
|
|
78
|
+
// The project's key function is wrong, which is a fault and not the caller's, so it is asked first.
|
|
79
|
+
if (adapter.prefix && !key.startsWith(`${adapter.prefix}/`)) {
|
|
80
|
+
throw new Error(`storage.key answered "${key}", which is not under adapter "${config.adapter}"'s prefix "${adapter.prefix}/".`);
|
|
81
|
+
}
|
|
82
|
+
// The row id is what keeps one object to one row: without it two uploads could share a key,
|
|
83
|
+
// and the unique key would answer whether a name is taken.
|
|
84
|
+
if (!key.split('/').includes(id))
|
|
85
|
+
throw new Error(`storage.key answered "${key}", which does not hold the row id "${id}" as a whole path segment.`);
|
|
86
|
+
// A key with an empty, `.` or `..` part, a backslash or a NUL climbs out of its prefix.
|
|
87
|
+
if (!key || /[\\\0]/.test(key) || key.split('/').some((part) => part === '' || part === '.' || part === '..')) {
|
|
88
|
+
throw invalid({ message: 'The upload names a file whose key would have an empty, "." or ".." part.', key: 'error.invalid.upload_request', params: { kayan: kayan.name } });
|
|
89
|
+
}
|
|
90
|
+
const unsigned = { id, key, kayan: kayan.name, tenant, filename, type, size, storage: config.adapter, exp: Math.floor(Date.now() / 1000) + referenceSeconds };
|
|
91
|
+
return { ...unsigned, signature: signatureOf(config.secret, unsigned) };
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Checks the reference a create names back, refusing every way it can be wrong in one
|
|
95
|
+
* sentence so a forged one reads as an expired one. The signature is compared in constant time.
|
|
96
|
+
*/
|
|
97
|
+
export const checkReference = async ({ kayan, value, actor }) => {
|
|
98
|
+
const storage = storageOf(kayan);
|
|
99
|
+
const refused = invalid({ message: 'The upload reference is not one this server signed for this file, this tenant and this hour.', key: 'error.invalid.upload_reference', params: { kayan: kayan.name } });
|
|
100
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
101
|
+
throw refused;
|
|
102
|
+
const held = value;
|
|
103
|
+
const strings = ['id', 'key', 'kayan', 'filename', 'type', 'storage', 'signature'];
|
|
104
|
+
if (strings.some((name) => typeof held[name] !== 'string'))
|
|
105
|
+
throw refused;
|
|
106
|
+
if (!Number.isSafeInteger(held.size) || !Number.isSafeInteger(held.exp) || (held.tenant !== null && typeof held.tenant !== 'string'))
|
|
107
|
+
throw refused;
|
|
108
|
+
const reference = Object.fromEntries([...fields, 'signature'].map((name) => [name, held[name]]));
|
|
109
|
+
const given = Buffer.from(reference.signature);
|
|
110
|
+
const expected = Buffer.from(signatureOf(storage.config.secret, reference));
|
|
111
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected))
|
|
112
|
+
throw refused;
|
|
113
|
+
if (reference.exp * 1000 < Date.now())
|
|
114
|
+
throw refused;
|
|
115
|
+
const tenant = storage.tenant(actor);
|
|
116
|
+
if (reference.kayan !== kayan.name || tenant === undefined || reference.tenant !== tenant)
|
|
117
|
+
throw refused;
|
|
118
|
+
if (!Object.hasOwn(storage.config.adapters, reference.storage))
|
|
119
|
+
throw refused;
|
|
120
|
+
// Refused before anything reads or deletes the object, which is the earlier row's.
|
|
121
|
+
if (await storage.exists(reference.id))
|
|
122
|
+
throw invalid({ message: 'The upload reference was already used.', key: 'error.invalid.upload_reference', params: { kayan: kayan.name } });
|
|
123
|
+
return reference;
|
|
124
|
+
};
|
|
125
|
+
/** The create shape of a kayan holding files, with the `uploadReference` beside it. */
|
|
126
|
+
export const withUploadReference = (create) => ({
|
|
127
|
+
'~standard': {
|
|
128
|
+
version: 1,
|
|
129
|
+
vendor: 'mithaq',
|
|
130
|
+
validate: async (value) => {
|
|
131
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
132
|
+
return { issues: [{ message: 'Invalid input: expected object' }] };
|
|
133
|
+
const { uploadReference, ...rest } = value;
|
|
134
|
+
const result = await create['~standard'].validate(rest);
|
|
135
|
+
return result.issues ? result : { value: { ...result.value, ...(uploadReference !== undefined && { uploadReference }) } };
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const readsOpen = (kayan) => {
|
|
140
|
+
try {
|
|
141
|
+
return kayan.access?.read?.({ user: null, slug: kayan.name }) === true;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
// The store is asked through `storeOf` because the build has it only once its stores are initialised.
|
|
148
|
+
export const resolveStorage = ({ language, resolved, storeOf, active }) => {
|
|
149
|
+
const existsIn = (kayan) => async (id) => {
|
|
150
|
+
const store = storeOf(kayan);
|
|
151
|
+
const { docs } = await store.find({ kayan: kayan.name, args: { limit: 1, page: 1, sort: { field: 'id', direction: 'asc' }, where: { id: { equals: id } } } });
|
|
152
|
+
return docs.length > 0;
|
|
153
|
+
};
|
|
154
|
+
// One storage target per build: two would split the kayan between two secrets and two sweeps.
|
|
155
|
+
const storageTargets = Object.keys(language.targets).filter((key) => isStorage(language.targets[key]));
|
|
156
|
+
if (storageTargets.length > 1) {
|
|
157
|
+
throw new Error(`Targets "${storageTargets.join('" and "')}" both declare capability "storage", and a build has at most one storage target. Drop one of them.`);
|
|
158
|
+
}
|
|
159
|
+
const name = storageTargets[0];
|
|
160
|
+
const holding = name ? resolved.filter((kayan) => kayan.targets?.[name] !== undefined) : [];
|
|
161
|
+
for (const kayan of resolved) {
|
|
162
|
+
for (const field of Object.values(kayan.fields)) {
|
|
163
|
+
if (field.kind === 'upload' && !holding.includes(field.target)) {
|
|
164
|
+
throw new Error(`Kayan "${kayan.name}" field "${field.name}" is an upload, and kayan "${field.target?.name}" it points at writes no targets.${name ?? 'storage'}, so it holds no files.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!name)
|
|
169
|
+
return [];
|
|
170
|
+
const { config } = language.targets[name];
|
|
171
|
+
const given = (config ?? {});
|
|
172
|
+
if (typeof given.secret !== 'string' || given.secret.length < 32)
|
|
173
|
+
throw new Error('storage.secret signs every upload reference and is a string of 32 characters at least.');
|
|
174
|
+
for (const [one, shipped] of Object.entries(storageFunctions)) {
|
|
175
|
+
if (typeof given[one] !== 'function')
|
|
176
|
+
throw new Error(`storage names no "${one}" function. Name the project's own, such as ${shipped} from src/storage/defaults.ts.`);
|
|
177
|
+
}
|
|
178
|
+
if (given.fileSize === undefined) {
|
|
179
|
+
throw new Error('storage names no "fileSize", the largest file in bytes a kayan holds when it names none. Name the project\'s own, such as defaultFileSize from mithaq.');
|
|
180
|
+
}
|
|
181
|
+
if (!Number.isSafeInteger(given.fileSize) || given.fileSize < 1) {
|
|
182
|
+
throw new Error(`storage.fileSize is ${JSON.stringify(given.fileSize)}, and it is a whole number of bytes above 0.`);
|
|
183
|
+
}
|
|
184
|
+
if (typeof given.onError !== 'function')
|
|
185
|
+
throw new Error('storage names no "onError" function, which a file route hands what it threw before it answers 500. Name the project\'s own, such as console.error.');
|
|
186
|
+
const adapters = Object.entries(given.adapters ?? {});
|
|
187
|
+
if (!adapters.length)
|
|
188
|
+
throw new Error('storage names no adapters, so no file has anywhere to go.');
|
|
189
|
+
for (const [adapter, held] of adapters) {
|
|
190
|
+
const missing = ['handleDelete', 'staticHandler', 'head', 'readHead'].find((one) => typeof held?.[one] !== 'function')
|
|
191
|
+
?? (typeof held?.uploadInstructions?.generate === 'function' ? undefined : 'uploadInstructions.generate')
|
|
192
|
+
?? (typeof held?.prefix === 'string' && (held.prefix || !held.list) ? undefined : 'prefix');
|
|
193
|
+
if (missing)
|
|
194
|
+
throw new Error(`storage adapter "${adapter}" has no "${missing}".`);
|
|
195
|
+
// A slash at either end would put keys outside what the sweep lists.
|
|
196
|
+
if (held.prefix.startsWith('/') || held.prefix.endsWith('/')) {
|
|
197
|
+
throw new Error(`storage adapter "${adapter}" names prefix "${held.prefix}", and a prefix is a folder of the store with no slash at either end.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (typeof given.adapter !== 'string' || !Object.hasOwn(given.adapters, given.adapter)) {
|
|
201
|
+
throw new Error(`storage.adapter names "${String(given.adapter)}", and new uploads go to one of storage.adapters, which are ${adapters.map(([one]) => `"${one}"`).join(', ')}.`);
|
|
202
|
+
}
|
|
203
|
+
for (const kayan of holding) {
|
|
204
|
+
const where = `Kayan "${kayan.name}" writes "targets.${name}"`;
|
|
205
|
+
const kept = kayan.targets[name];
|
|
206
|
+
if (kayan.single)
|
|
207
|
+
throw new Error(`${where}, and a global holds no files: files are a kayan's rows.`);
|
|
208
|
+
if (kayan.versions)
|
|
209
|
+
throw new Error(`${where} and keeps versions, and a file never changes after it is made, so there is nothing to version. Version the kayan that points at it instead.`);
|
|
210
|
+
if (!Array.isArray(kept.mimeTypes) || !kept.mimeTypes.length || kept.mimeTypes.some((one) => typeof one !== 'string' || !/^[\w.+-]+\/(?:[\w.+-]+|\*)$/.test(one))) {
|
|
211
|
+
throw new Error(`${where} with mimeTypes ${JSON.stringify(kept.mimeTypes)}, and mimeTypes is a list of the types it holds, such as ["image/png"] or ["image/*"].`);
|
|
212
|
+
}
|
|
213
|
+
const fileSize = kept.fileSize ?? config.fileSize;
|
|
214
|
+
if (!Number.isSafeInteger(fileSize) || fileSize < 1)
|
|
215
|
+
throw new Error(`${where} with fileSize ${String(kept.fileSize)}, and fileSize is a whole number of bytes above 0.`);
|
|
216
|
+
if (kept.public !== undefined && kept.public !== true)
|
|
217
|
+
throw new Error(`${where} with public ${String(kept.public)}, and public is true or left out.`);
|
|
218
|
+
for (const [column, shipped] of Object.entries(uploadFields)) {
|
|
219
|
+
const field = kayan.fields[column];
|
|
220
|
+
if (!field || field.kind !== shipped.kind || providerOf(field, 'create') === 'client' || providerOf(field, 'update') !== 'never') {
|
|
221
|
+
throw new Error(`${where}, and its field "${column}" is not the shipped one, which the runner writes on create and nobody writes after. Spread uploadFields into its fields.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const makers = Object.entries(kayan.operations).filter(([, one]) => one.steps.some((step) => sealOf(step) === 'insert'));
|
|
225
|
+
if (!makers.length) {
|
|
226
|
+
throw new Error(`${where}, and no operation of it holds the "insert" step, so no file can be made. Declare one that runs storage.uploaded in beforeTransaction.`);
|
|
227
|
+
}
|
|
228
|
+
if (makers.length > 1) {
|
|
229
|
+
throw new Error(`${where}, and operations ${makers.map(([one]) => `"${one}"`).join(' and ')} ${makers.length > 2 ? 'all' : 'both'} hold the "insert" step. A kayan that holds files makes its rows in one, the operation the upload instructions route asks the permission of.`);
|
|
230
|
+
}
|
|
231
|
+
const [operation, made] = makers[0];
|
|
232
|
+
if (!made.beforeTransaction.includes(config.uploaded)) {
|
|
233
|
+
throw new Error(`${where}, and its operation "${operation}" does not verify the upload before the row is made: its beforeTransaction does not run storage.uploaded, the step the storage rukn was handed.`);
|
|
234
|
+
}
|
|
235
|
+
// A marked row still names its object and a restore brings it back, so removing
|
|
236
|
+
// the object there loses a file a row names.
|
|
237
|
+
if (kayan.softDelete) {
|
|
238
|
+
for (const [one, declared] of Object.entries(kayan.operations)) {
|
|
239
|
+
const slot = ['beforeTransaction', 'steps', 'afterSteps', 'afterCommit'].find((list) => declared[list].includes(config.removed));
|
|
240
|
+
if (slot)
|
|
241
|
+
throw new Error(`${where} and marks its rows deleted, and its operation "${one}" runs storage.removed in "${slot}", which would remove the object of a row that is only marked. Leave storage.removed out of its lists.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!kayan.indexes.some((index) => index.unique && index.fields.length === 1 && index.fields[0] === 'key')) {
|
|
245
|
+
throw new Error(`${where}, and builds no unique index over "key" alone, while a key is the address of one object in a store every kayan and organization shares. Keep the default unique index over "key" in indexes.`);
|
|
246
|
+
}
|
|
247
|
+
if (kept.public && kayan.tenant) {
|
|
248
|
+
throw new Error(`${where} with public: true, and its rows belong to an organization, so a public address would hand one organization's file to anyone who has it. Drop public.`);
|
|
249
|
+
}
|
|
250
|
+
if (kept.public && !readsOpen(kayan)) {
|
|
251
|
+
throw new Error(`${where} with public: true, and its read is not open to everyone, so a public address would hand out what its access keeps back. Drop public, or open its read.`);
|
|
252
|
+
}
|
|
253
|
+
if (kept.public && typeof config.adapters[config.adapter].generateURL !== 'function') {
|
|
254
|
+
throw new Error(`${where} with public: true, and storage adapter "${config.adapter}" has no generateURL, so it has no public address.`);
|
|
255
|
+
}
|
|
256
|
+
// A shared kayan's files belong to no organization, whichever session uploads them.
|
|
257
|
+
const tenant = kayan.tenant ? active : () => null;
|
|
258
|
+
kayan.storage = { mimeTypes: kept.mimeTypes, fileSize, public: !!kept.public, config, operation, exists: existsIn(kayan), tenant };
|
|
259
|
+
}
|
|
260
|
+
return holding;
|
|
261
|
+
};
|
|
262
|
+
const filePath = (kayan, id) => `/api/${kayan}/file/${encodeURIComponent(String(id))}`;
|
|
263
|
+
const raster = /^image\/(png|jpeg|gif|webp|avif)$/;
|
|
264
|
+
/** A row's `url` and `thumbnailURL`, computed on read: the file route, or the adapter's public address. */
|
|
265
|
+
export const fileURLs = ({ kayan, stored, visible, }) => {
|
|
266
|
+
const storage = kayan.storage;
|
|
267
|
+
if (!storage || !('id' in visible) || typeof stored.key !== 'string')
|
|
268
|
+
return visible;
|
|
269
|
+
const publicURL = storage.public ? adapterOf({ kayan, name: stored.storage }).generateURL?.({ key: stored.key }) : undefined;
|
|
270
|
+
const url = publicURL ?? filePath(kayan.name, stored.id);
|
|
271
|
+
return { ...visible, url, thumbnailURL: typeof stored.mimeType === 'string' && raster.test(stored.mimeType) ? url : null };
|
|
272
|
+
};
|
|
273
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
274
|
+
const refusal = (thrown) => Response.json({ code: codes[thrown.status], data: { key: thrown.key, params: thrown.params }, message: thrown.message }, { status: thrown.status });
|
|
275
|
+
/**
|
|
276
|
+
* The row a file route serves, read through the kayan's own `findByID` as the actor. Every
|
|
277
|
+
* reason it cannot be served is the same 404, so no answer tells one from another.
|
|
278
|
+
*/
|
|
279
|
+
export const readForFile = async ({ kayan, id, read }) => {
|
|
280
|
+
let row = null;
|
|
281
|
+
try {
|
|
282
|
+
const named = decodeURIComponent(id);
|
|
283
|
+
if (kayan.fields.id?.kind !== 'uuid' || uuid.test(named))
|
|
284
|
+
row = await read({ id: named });
|
|
285
|
+
}
|
|
286
|
+
catch (thrown) {
|
|
287
|
+
if (!(thrown instanceof AsasError) && !(thrown instanceof URIError))
|
|
288
|
+
throw thrown;
|
|
289
|
+
}
|
|
290
|
+
if (!row || [row.key, row.storage, row.mimeType, row.filename].some((one) => typeof one !== 'string')) {
|
|
291
|
+
throw missing({ message: `No "${kayan.name}" file here.`, key: 'error.not_found.file', params: { kayan: kayan.name } });
|
|
292
|
+
}
|
|
293
|
+
return row;
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* Serves a file under the headers its bytes need. An SVG is never `inline` and
|
|
297
|
+
* gets a policy that runs no script.
|
|
298
|
+
*/
|
|
299
|
+
export const fileHeaders = async ({ doc, disposition, answer, }) => {
|
|
300
|
+
const safe = doc.mimeType === svg ? disposition.replace(/^\s*inline/i, 'attachment') : disposition;
|
|
301
|
+
const answered = await answer({ disposition: safe });
|
|
302
|
+
const headers = new Headers(answered.headers);
|
|
303
|
+
if (answered.status !== 302) {
|
|
304
|
+
headers.set('Content-Disposition', safe);
|
|
305
|
+
headers.set('Content-Type', doc.mimeType);
|
|
306
|
+
if (doc.mimeType === svg)
|
|
307
|
+
headers.set('Content-Security-Policy', "script-src 'none'");
|
|
308
|
+
}
|
|
309
|
+
return new Response(answered.body, { status: answered.status, headers });
|
|
310
|
+
};
|
|
311
|
+
// Asked by the build's handler before any plug.
|
|
312
|
+
export const storageRoutes = ({ kayan, config, read, authorize, admin, }) => {
|
|
313
|
+
const byName = new Map(kayan.map((one) => [one.name, one]));
|
|
314
|
+
return async (request, who) => {
|
|
315
|
+
for (const adapter of Object.values(config.adapters)) {
|
|
316
|
+
const answered = await adapter.handler?.(request);
|
|
317
|
+
if (answered)
|
|
318
|
+
return answered;
|
|
319
|
+
}
|
|
320
|
+
const [api, name, verb, id, ...rest] = new URL(request.url).pathname.split('/').slice(1);
|
|
321
|
+
const one = api === 'api' && name ? byName.get(name) : undefined;
|
|
322
|
+
if (!one)
|
|
323
|
+
return null;
|
|
324
|
+
try {
|
|
325
|
+
if (verb === 'uploadInstructions' && id === undefined && request.method === 'POST') {
|
|
326
|
+
let input;
|
|
327
|
+
try {
|
|
328
|
+
input = await request.json();
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
input = undefined;
|
|
332
|
+
}
|
|
333
|
+
const actor = await who();
|
|
334
|
+
// Asked by core, so no project's instructions sign an upload for someone who may not make the row.
|
|
335
|
+
await authorize({ admin, actor, data: {}, effect: 'create', kayan: one, kind: 'insert', operation: one.storage.operation });
|
|
336
|
+
return Response.json(await config.instructions({ kayan: one, input, actor }));
|
|
337
|
+
}
|
|
338
|
+
if (verb !== 'file' || !id || rest.length || request.method !== 'GET')
|
|
339
|
+
return null;
|
|
340
|
+
const served = await config.serve({ request, kayan: one, id, read: async ({ id }) => read({ kayan: one.name, id, actor: await who() }) });
|
|
341
|
+
// Set here rather than by `serve`, so no project can leave them off: no sniffing,
|
|
342
|
+
// and no cache keeps a private file or the address a redirect hands out for it.
|
|
343
|
+
const headers = new Headers(served.headers);
|
|
344
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
345
|
+
if (!one.storage.public)
|
|
346
|
+
headers.set('Cache-Control', 'private, no-store');
|
|
347
|
+
return new Response(served.body, { status: served.status, statusText: served.statusText, headers });
|
|
348
|
+
}
|
|
349
|
+
catch (thrown) {
|
|
350
|
+
if (thrown instanceof AsasError)
|
|
351
|
+
return refusal(thrown);
|
|
352
|
+
config.onError(thrown);
|
|
353
|
+
return Response.json({ code: 'INTERNAL_SERVER_ERROR', message: 'Internal server error' }, { status: 500 });
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
};
|
|
357
|
+
const built = new WeakMap();
|
|
358
|
+
export const recordStorage = (asas, swept) => void built.set(asas, swept);
|
|
359
|
+
// A row marked deleted still names its key, so its object stays.
|
|
360
|
+
const sweep = async ({ asas, flags }) => {
|
|
361
|
+
const swept = built.get(asas);
|
|
362
|
+
if (!swept) {
|
|
363
|
+
console.log('This build names no storage, so there is nothing to sweep.');
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
const check = flags.includes('--check');
|
|
367
|
+
// A pool of its own wherever the store was made from a string: `mithaq generate
|
|
368
|
+
// --check` runs every command after a generator has destroyed the build.
|
|
369
|
+
const pools = [];
|
|
370
|
+
try {
|
|
371
|
+
const named = new Map();
|
|
372
|
+
for (const one of swept.kayan) {
|
|
373
|
+
const store = swept.storeOf(one);
|
|
374
|
+
const string = store.connectionString;
|
|
375
|
+
const own = store.sql === 'drizzle' && typeof string === 'string' && string ? drizzle(string) : undefined;
|
|
376
|
+
if (own)
|
|
377
|
+
pools.push(own);
|
|
378
|
+
const { docs } = await store.find({ kayan: one.name, args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' } }, tx: own });
|
|
379
|
+
for (const row of docs) {
|
|
380
|
+
const set = named.get(String(row.storage)) ?? new Set();
|
|
381
|
+
set.add(String(row.key));
|
|
382
|
+
named.set(String(row.storage), set);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (const [name, adapter] of Object.entries(swept.config.adapters)) {
|
|
386
|
+
if (!adapter.list) {
|
|
387
|
+
console.log(`Adapter "${name}" lists no objects, so it is not swept.`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const orphans = await swept.config.sweep({ adapter, named: named.get(name) ?? new Set(), check, now: new Date() });
|
|
391
|
+
for (const key of orphans)
|
|
392
|
+
console.log(`${check ? 'Orphan' : 'Removed'} in "${name}": ${key}`);
|
|
393
|
+
if (!orphans.length)
|
|
394
|
+
console.log(`Adapter "${name}" holds no orphan old enough to sweep.`);
|
|
395
|
+
}
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
for (const pool of pools)
|
|
400
|
+
await pool.$client.end().catch(() => { });
|
|
401
|
+
await asas.destroy().catch(() => { });
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
/** The storage target over the project's config. The storage rukn builds it. */
|
|
405
|
+
export const storageTarget = (config) => ({ capability: 'storage', type: 'storage', config, commands: { sweep } });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** `max` is how many versions of one row are kept, the oldest deleted past it; `0` keeps every one. */
|
|
2
|
+
export type VersionsTarget = {
|
|
3
|
+
readonly capability: 'versions';
|
|
4
|
+
readonly type: 'versions';
|
|
5
|
+
readonly max: number;
|
|
6
|
+
};
|
|
7
|
+
/** What a kayan writes under the versions target: whether it keeps drafts, autosave, and its own window. */
|
|
8
|
+
export type KayanVersions = {
|
|
9
|
+
readonly drafts?: true | {
|
|
10
|
+
readonly autosave?: true | {
|
|
11
|
+
readonly interval: number;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
readonly max?: number;
|
|
15
|
+
};
|
|
16
|
+
/** The versions target, keeping `max` versions of a row where a kayan names no window of its own. */
|
|
17
|
+
export declare const versionsTarget: ({ max }: {
|
|
18
|
+
max: number;
|
|
19
|
+
}) => VersionsTarget;
|
|
20
|
+
declare module '../mithaq/rukn.ts' {
|
|
21
|
+
interface KayanConfigs<L, F, T> {
|
|
22
|
+
versions: KayanVersions;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The rukn a project lays down for versions and drafts: `arkan: [base, versions({ max: 100 })]`.
|
|
27
|
+
* Each kayan that keeps versions writes `targets.versions` and spreads `versionOperations`.
|
|
28
|
+
*/
|
|
29
|
+
export declare const versions: ({ max }: {
|
|
30
|
+
max: number;
|
|
31
|
+
}) => {
|
|
32
|
+
readonly name: "versions";
|
|
33
|
+
readonly targets: {
|
|
34
|
+
versions: VersionsTarget;
|
|
35
|
+
};
|
|
36
|
+
readonly needs: readonly ["store"];
|
|
37
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineRukn } from '../mithaq/rukn.js';
|
|
2
|
+
/** The versions target, keeping `max` versions of a row where a kayan names no window of its own. */
|
|
3
|
+
export const versionsTarget = ({ max }) => ({ capability: 'versions', type: 'versions', max });
|
|
4
|
+
/**
|
|
5
|
+
* The rukn a project lays down for versions and drafts: `arkan: [base, versions({ max: 100 })]`.
|
|
6
|
+
* Each kayan that keeps versions writes `targets.versions` and spreads `versionOperations`.
|
|
7
|
+
*/
|
|
8
|
+
export const versions = ({ max }) => defineRukn({ name: 'versions', targets: { versions: versionsTarget({ max }) }, needs: ['store'] });
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ResolvedKayan } from '../mithaq/types.ts';
|
|
2
|
+
import type { Store } from '../mithaq/language.ts';
|
|
3
|
+
import type { Authorize, ManagedAdmin } from '../asas/runner/write.ts';
|
|
4
|
+
import type { Where } from '../mithaq/where.ts';
|
|
5
|
+
import type { KayanVersions } from './index.ts';
|
|
6
|
+
/**
|
|
7
|
+
* The `_status` field a kayan that keeps drafts spreads into its own `fields`.
|
|
8
|
+
* A plain read leaves out a row that is not `published`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const draftFields: {
|
|
11
|
+
readonly _status: {
|
|
12
|
+
readonly kind: 'text';
|
|
13
|
+
readonly label: {
|
|
14
|
+
readonly en: 'Status';
|
|
15
|
+
readonly ar: 'الحالة';
|
|
16
|
+
};
|
|
17
|
+
readonly options: readonly [{
|
|
18
|
+
readonly value: 'draft';
|
|
19
|
+
readonly label: {
|
|
20
|
+
readonly en: 'Draft';
|
|
21
|
+
readonly ar: 'مسودة';
|
|
22
|
+
};
|
|
23
|
+
}, {
|
|
24
|
+
readonly value: 'published';
|
|
25
|
+
readonly label: {
|
|
26
|
+
readonly en: 'Published';
|
|
27
|
+
readonly ar: 'منشور';
|
|
28
|
+
};
|
|
29
|
+
}];
|
|
30
|
+
readonly rules: {
|
|
31
|
+
readonly defaultValue: 'draft';
|
|
32
|
+
readonly index: true;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The three operations a kayan that keeps versions spreads beside `defaultOperations`:
|
|
38
|
+
* `findVersions`, `findVersionByID` and `restoreVersion`.
|
|
39
|
+
*/
|
|
40
|
+
export declare const versionOperations: {
|
|
41
|
+
findVersions: {
|
|
42
|
+
steps: (import("../asas/runner/steps.ts").ReadStep<Record<string, unknown>, Record<string, unknown>, unknown, unknown, unknown, Record<string, unknown>> & {
|
|
43
|
+
readonly __seal: "findVersions";
|
|
44
|
+
})[];
|
|
45
|
+
};
|
|
46
|
+
findVersionByID: {
|
|
47
|
+
steps: (import("../asas/runner/steps.ts").ReadStep<Record<string, unknown>, Record<string, unknown>, unknown, unknown, unknown, Record<string, unknown>> & {
|
|
48
|
+
readonly __seal: "findVersionByID";
|
|
49
|
+
})[];
|
|
50
|
+
};
|
|
51
|
+
restoreVersion: {
|
|
52
|
+
steps: (import("../asas/runner/steps.ts").WriteStep<Record<string, unknown>, Record<string, unknown>, unknown, unknown, unknown, Record<string, unknown>> & {
|
|
53
|
+
readonly __seal: "restoreVersion";
|
|
54
|
+
})[];
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
export declare const versionsOf: ({ kayan, kept, name, window, }: {
|
|
58
|
+
kayan: ResolvedKayan;
|
|
59
|
+
kept: KayanVersions;
|
|
60
|
+
name: string;
|
|
61
|
+
window: number;
|
|
62
|
+
}) => NonNullable<ResolvedKayan['versions']>;
|
|
63
|
+
type Versioned = ResolvedKayan & {
|
|
64
|
+
versions: NonNullable<ResolvedKayan['versions']>;
|
|
65
|
+
};
|
|
66
|
+
export declare const revived: (kayan: ResolvedKayan, snapshot: unknown) => Record<string, unknown>;
|
|
67
|
+
export declare const latestVersion: ({ kayan, store, parent, tx, }: {
|
|
68
|
+
kayan: Versioned;
|
|
69
|
+
store: Pick<Store, 'find'>;
|
|
70
|
+
parent: unknown;
|
|
71
|
+
tx: unknown;
|
|
72
|
+
}) => Promise<Record<string, unknown> | undefined>;
|
|
73
|
+
export declare const saveVersion: ({ kayan, store, writes, tx, row, autosave, }: {
|
|
74
|
+
kayan: Versioned;
|
|
75
|
+
store: Pick<Store, 'find'>;
|
|
76
|
+
writes: Pick<Store, 'insert' | 'remove' | 'update'>;
|
|
77
|
+
tx: unknown;
|
|
78
|
+
row: Record<string, unknown>;
|
|
79
|
+
autosave: boolean;
|
|
80
|
+
}) => Promise<void>;
|
|
81
|
+
export declare const changedColumns: ({ kayan, snapshot, row, whole, }: {
|
|
82
|
+
kayan: ResolvedKayan;
|
|
83
|
+
snapshot: Record<string, unknown>;
|
|
84
|
+
row: Record<string, unknown>;
|
|
85
|
+
whole?: boolean;
|
|
86
|
+
}) => {
|
|
87
|
+
[k: string]: {} | null;
|
|
88
|
+
};
|
|
89
|
+
export declare const restoreInput: <Actor>({ kayan, store, data, actor, authorize, admin, alive, seesDrafts, admits, }: {
|
|
90
|
+
kayan: Versioned;
|
|
91
|
+
store: Store;
|
|
92
|
+
data: Record<string, unknown>;
|
|
93
|
+
actor: Actor | null;
|
|
94
|
+
authorize: Authorize;
|
|
95
|
+
admin: ManagedAdmin<Actor> | undefined;
|
|
96
|
+
alive: Where | undefined;
|
|
97
|
+
/** Whether the actor holds `findVersions`, which every restore asks. */
|
|
98
|
+
seesDrafts: boolean;
|
|
99
|
+
/** The rows that key's answer admits, where it limits them: a restore and a discard reach no other. */
|
|
100
|
+
admits?: Where;
|
|
101
|
+
}) => Promise<{
|
|
102
|
+
id: unknown;
|
|
103
|
+
}>;
|
|
104
|
+
export {};
|