@wtfalch/foundry 0.60.0 → 0.62.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/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +7 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +184 -24
- package/dist/org.js +444 -49
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +60 -8
- package/dist/plan/blue-green.js +115 -10
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +15 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +552 -104
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-access.js +1 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +231 -19
- package/dist/plan/executor.js +1648 -129
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +7 -4
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +15 -2
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +13 -2
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +12 -3
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/providers-readers.js +20 -1
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +13 -1
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +20 -7
- package/dist/store/postgres.js +25 -2
- package/dist/store/schema.d.ts +73 -1
- package/dist/store/schema.js +10 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +19 -2
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import { SECRET_LIKE_NAME, WORKER_APP_ENV_NAME, WORKER_APP_RESERVED_NAMES, } from '../entry-rules.js';
|
|
2
|
+
import { ai } from './ai.js';
|
|
3
|
+
import { assistants } from './assistants.js';
|
|
4
|
+
import { audit } from './audit.js';
|
|
5
|
+
import { database } from './database.js';
|
|
6
|
+
import { email } from './email.js';
|
|
7
|
+
import { clone, isProblem } from './entry-parts.js';
|
|
8
|
+
import { files } from './files.js';
|
|
9
|
+
import { jobs } from './jobs.js';
|
|
10
|
+
import { organisations } from './organisations.js';
|
|
11
|
+
import { SECRET_OUTPUTS, referenceProblem } from './outputs.js';
|
|
12
|
+
import { signIn } from './sign-in.js';
|
|
13
|
+
import { signedRecords } from './signed-records.js';
|
|
14
|
+
import { workspaces } from './workspaces.js';
|
|
15
|
+
export { databaseAddressesOf } from './database.js';
|
|
16
|
+
/**
|
|
17
|
+
* Every module, in the order they are applied: a module comes after the ones it needs, so sign-in sets
|
|
18
|
+
* `uses.auth` before help for AI assistants and workspaces add to it.
|
|
19
|
+
*/
|
|
20
|
+
export const MODULES = [
|
|
21
|
+
signIn,
|
|
22
|
+
assistants,
|
|
23
|
+
workspaces,
|
|
24
|
+
database,
|
|
25
|
+
signedRecords,
|
|
26
|
+
files,
|
|
27
|
+
email,
|
|
28
|
+
jobs,
|
|
29
|
+
ai,
|
|
30
|
+
organisations,
|
|
31
|
+
audit,
|
|
32
|
+
];
|
|
33
|
+
const byId = new Map(MODULES.map((module) => [module.view.id, module]));
|
|
34
|
+
/** The views of every module, JSON-safe, for Boule to render. */
|
|
35
|
+
export function modulesCatalogue() {
|
|
36
|
+
return MODULES.map((module) => clone(module.view));
|
|
37
|
+
}
|
|
38
|
+
const RUNTIME_WORDS = { server: 'an app that runs on a server', worker: 'a Worker app' };
|
|
39
|
+
/** The values with every absent field's default filled in. */
|
|
40
|
+
function withDefaults(view, values) {
|
|
41
|
+
const filled = { ...values };
|
|
42
|
+
for (const field of view.fields) {
|
|
43
|
+
if (filled[field.key] === undefined && field.default !== undefined) {
|
|
44
|
+
filled[field.key] = clone(field.default);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return filled;
|
|
48
|
+
}
|
|
49
|
+
const isEmpty = (value) => value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
|
|
50
|
+
/** A plain sentence when one field's value does not fit the field, else null. */
|
|
51
|
+
function fieldProblem(field, value) {
|
|
52
|
+
const name = `"${field.label}"`;
|
|
53
|
+
if (isEmpty(value))
|
|
54
|
+
return field.required && field.kind !== 'secret' ? `${name} is needed.` : null;
|
|
55
|
+
const pattern = field.pattern === undefined ? null : new RegExp(field.pattern, 'u');
|
|
56
|
+
switch (field.kind) {
|
|
57
|
+
case 'switch':
|
|
58
|
+
return typeof value === 'boolean' ? null : `${name} must be on or off.`;
|
|
59
|
+
case 'number':
|
|
60
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
61
|
+
return `${name} must be a number.`;
|
|
62
|
+
return field.max !== undefined && value > field.max
|
|
63
|
+
? `${name} may be at most ${field.max}.`
|
|
64
|
+
: null;
|
|
65
|
+
case 'list': {
|
|
66
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
|
|
67
|
+
return `${name} must be a list of words.`;
|
|
68
|
+
}
|
|
69
|
+
if (field.max !== undefined && value.length > field.max) {
|
|
70
|
+
return `${name} may hold at most ${field.max}.`;
|
|
71
|
+
}
|
|
72
|
+
if (new Set(value).size !== value.length)
|
|
73
|
+
return `${name} names something twice.`;
|
|
74
|
+
for (const item of value) {
|
|
75
|
+
if (field.choices && !field.choices.some((choice) => choice.value === item)) {
|
|
76
|
+
return `${name} cannot hold "${item}".`;
|
|
77
|
+
}
|
|
78
|
+
if (pattern && !pattern.test(item))
|
|
79
|
+
return `${name}: "${item}" is not written the way it must be.`;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
case 'choice': {
|
|
84
|
+
const choice = field.choices?.find((c) => c.value === value);
|
|
85
|
+
if (!choice)
|
|
86
|
+
return `${name} must be one of the choices shown.`;
|
|
87
|
+
return choice.soon ? `"${choice.label}" is coming soon. Pick another choice for now.` : null;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
if (typeof value !== 'string')
|
|
91
|
+
return `${name} must be text.`;
|
|
92
|
+
if (field.max !== undefined && value.length > field.max) {
|
|
93
|
+
return `${name} may be at most ${field.max} characters.`;
|
|
94
|
+
}
|
|
95
|
+
return pattern && !pattern.test(value) ? `${name} is not written the way it must be.` : null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** The modules with their defaults filled, for the modules' own checks and `toEntry`. */
|
|
99
|
+
function filledModules(modules) {
|
|
100
|
+
const filled = {};
|
|
101
|
+
for (const [id, values] of Object.entries(modules)) {
|
|
102
|
+
const module = byId.get(id);
|
|
103
|
+
filled[id] = module ? withDefaults(module.view, values) : values;
|
|
104
|
+
}
|
|
105
|
+
return filled;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Null when the modules can be turned on together for an app on `ctx.runtime`; else the first problem, in
|
|
109
|
+
* words a company person can act on.
|
|
110
|
+
*/
|
|
111
|
+
export function checkModules(modules, ctx) {
|
|
112
|
+
if (typeof modules !== 'object' || modules === null || Array.isArray(modules)) {
|
|
113
|
+
return { module: '', field: null, message: 'The app’s modules must be a list of named parts.' };
|
|
114
|
+
}
|
|
115
|
+
for (const id of Object.keys(modules)) {
|
|
116
|
+
if (!byId.has(id)) {
|
|
117
|
+
return { module: id, field: null, message: `There is no module called "${id}".` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const filled = filledModules(modules);
|
|
121
|
+
for (const module of MODULES) {
|
|
122
|
+
const id = module.view.id;
|
|
123
|
+
const raw = modules[id];
|
|
124
|
+
if (raw === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
const refuse = (field, message) => ({
|
|
127
|
+
module: id,
|
|
128
|
+
field,
|
|
129
|
+
message,
|
|
130
|
+
});
|
|
131
|
+
const { view } = module;
|
|
132
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
133
|
+
return refuse(null, `The settings of ${view.name} must be a list of named values.`);
|
|
134
|
+
}
|
|
135
|
+
if (view.availability === 'soon') {
|
|
136
|
+
return refuse(null, `${view.name} is coming soon. It cannot be turned on yet.`);
|
|
137
|
+
}
|
|
138
|
+
if (!view.runtimes.includes(ctx.runtime)) {
|
|
139
|
+
return refuse(null, `${view.name} works only in ${view.runtimes.map((r) => RUNTIME_WORDS[r]).join(' or ')}. This app is ${RUNTIME_WORDS[ctx.runtime]}.`);
|
|
140
|
+
}
|
|
141
|
+
for (const needed of view.requires) {
|
|
142
|
+
if (modules[needed] === undefined) {
|
|
143
|
+
const other = byId.get(needed)?.view.name ?? needed;
|
|
144
|
+
return refuse(null, `${view.name} needs ${other}. Turn on ${other} first.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const values = filled[id];
|
|
148
|
+
for (const key of Object.keys(values)) {
|
|
149
|
+
if (!view.fields.some((field) => field.key === key)) {
|
|
150
|
+
return refuse(key, `${view.name} has no setting called "${key}".`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const field of view.fields) {
|
|
154
|
+
if (field.runtimes && !field.runtimes.includes(ctx.runtime)) {
|
|
155
|
+
if (isEmpty(values[field.key]))
|
|
156
|
+
continue;
|
|
157
|
+
return refuse(field.key, `"${field.label}" can only be set for ${field.runtimes.map((r) => RUNTIME_WORDS[r]).join(' or ')}. This app is ${RUNTIME_WORDS[ctx.runtime]}.`);
|
|
158
|
+
}
|
|
159
|
+
const message = fieldProblem(field, values[field.key]);
|
|
160
|
+
if (message)
|
|
161
|
+
return refuse(field.key, message);
|
|
162
|
+
}
|
|
163
|
+
const ruled = module.check?.(values, filled, ctx);
|
|
164
|
+
if (ruled)
|
|
165
|
+
return refuse(ruled.field, ruled.message);
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* `entry` with each module in `modules` set and every part an absent module owns removed. The rest of the
|
|
171
|
+
* entry (its build, address, env) is kept as it is. The caller still validates the whole company
|
|
172
|
+
* (`validateHosting`).
|
|
173
|
+
*/
|
|
174
|
+
export function applyModules(entry, modules, ctx) {
|
|
175
|
+
const refused = checkModules(modules, ctx);
|
|
176
|
+
if (refused)
|
|
177
|
+
return { ok: false, ...refused };
|
|
178
|
+
if (ctx.runtime === 'worker' && !entry.workerApp) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
module: '',
|
|
182
|
+
field: null,
|
|
183
|
+
message: 'This app runs as a Worker app, but its build has not been read yet. Read the build first.',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (ctx.runtime === 'server' && entry.workerApp) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
module: '',
|
|
190
|
+
field: null,
|
|
191
|
+
message: 'This app runs as a Worker app, not on a server.',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const filled = filledModules(modules);
|
|
195
|
+
let next = clone(entry);
|
|
196
|
+
for (const module of MODULES) {
|
|
197
|
+
const id = module.view.id;
|
|
198
|
+
const result = module.toEntry(next, filled[id] ?? null, ctx);
|
|
199
|
+
if (isProblem(result))
|
|
200
|
+
return { ok: false, module: id, ...result };
|
|
201
|
+
next = result;
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, entry: next };
|
|
204
|
+
}
|
|
205
|
+
/** The modules `entry` has, with their values: the inverse of {@link applyModules}. */
|
|
206
|
+
export function modulesOf(entry) {
|
|
207
|
+
const out = {};
|
|
208
|
+
for (const module of MODULES) {
|
|
209
|
+
if (module.view.availability !== 'ready')
|
|
210
|
+
continue;
|
|
211
|
+
const values = module.fromEntry(entry);
|
|
212
|
+
if (values !== null)
|
|
213
|
+
out[module.view.id] = values;
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
const MANIFEST_V1_KEYS = ['v', 'runtime', 'modules', 'secrets', 'settings'];
|
|
218
|
+
const SECRET_KINDS = ['generated', 'held'];
|
|
219
|
+
const SETTING_KEYS = ['label', 'help', 'default'];
|
|
220
|
+
const SETTING_LABEL_MAX = 80;
|
|
221
|
+
const SETTING_HELP_MAX = 200;
|
|
222
|
+
const SETTING_DEFAULT_MAX = 1000;
|
|
223
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
224
|
+
/** A plain sentence when `name` cannot be a secret's or setting's name, else null. */
|
|
225
|
+
function nameProblem(kind, name) {
|
|
226
|
+
if (!WORKER_APP_ENV_NAME.test(name)) {
|
|
227
|
+
return `the ${kind} "${name}" must be named in capital letters, digits and "_", such as POOL_CEILING`;
|
|
228
|
+
}
|
|
229
|
+
if (WORKER_APP_RESERVED_NAMES.test(name)) {
|
|
230
|
+
return `the ${kind} "${name}" uses a name the platform sets itself (AUTH_, KEYS_, MCP_, HYPERDRIVE)`;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/** A v1 `company-app.json` read and checked. */
|
|
235
|
+
function parseManifestV1(json) {
|
|
236
|
+
const refuse = (message) => ({
|
|
237
|
+
ok: false,
|
|
238
|
+
message: `company-app.json: ${message}`,
|
|
239
|
+
});
|
|
240
|
+
const unknown = Object.keys(json).find((key) => !MANIFEST_V1_KEYS.includes(key));
|
|
241
|
+
if (unknown !== undefined)
|
|
242
|
+
return refuse(`"${unknown}" is not something the file can say.`);
|
|
243
|
+
if (json.v !== 1)
|
|
244
|
+
return refuse('"v" must be 1, the only version there is.');
|
|
245
|
+
const runtime = json.runtime;
|
|
246
|
+
if (runtime !== 'server' && runtime !== 'worker') {
|
|
247
|
+
return refuse('"runtime" must be "worker" (a Worker app) or "server" (an app that runs on a server).');
|
|
248
|
+
}
|
|
249
|
+
const modules = json.modules ?? {};
|
|
250
|
+
if (!isRecord(modules))
|
|
251
|
+
return refuse('"modules" must be an object of module names.');
|
|
252
|
+
const refused = checkModules(modules, { runtime });
|
|
253
|
+
if (refused)
|
|
254
|
+
return refuse(refused.message);
|
|
255
|
+
for (const [id, values] of Object.entries(modules)) {
|
|
256
|
+
const view = byId.get(id)?.view;
|
|
257
|
+
const secret = view?.fields.find((f) => f.kind === 'secret' && !isEmpty(values[f.key]));
|
|
258
|
+
if (view && secret) {
|
|
259
|
+
return refuse(`"${secret.label}" (${view.name}) is a secret. Your company types it in when it sets up the app; it never goes in this file.`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const secrets = {};
|
|
263
|
+
const rawSecrets = json.secrets ?? {};
|
|
264
|
+
if (!isRecord(rawSecrets))
|
|
265
|
+
return refuse('"secrets" must be an object of secret names.');
|
|
266
|
+
for (const [name, kind] of Object.entries(rawSecrets)) {
|
|
267
|
+
const bad = nameProblem('secret', name);
|
|
268
|
+
if (bad)
|
|
269
|
+
return refuse(`${bad}.`);
|
|
270
|
+
if (!SECRET_KINDS.includes(kind)) {
|
|
271
|
+
return refuse(`the secret "${name}" must be "generated" (made for you) or "held" (your company types it in).`);
|
|
272
|
+
}
|
|
273
|
+
secrets[name] = kind;
|
|
274
|
+
}
|
|
275
|
+
const settings = {};
|
|
276
|
+
const rawSettings = json.settings ?? {};
|
|
277
|
+
if (!isRecord(rawSettings))
|
|
278
|
+
return refuse('"settings" must be an object of setting names.');
|
|
279
|
+
for (const [name, raw] of Object.entries(rawSettings)) {
|
|
280
|
+
const bad = nameProblem('setting', name);
|
|
281
|
+
if (bad)
|
|
282
|
+
return refuse(`${bad}.`);
|
|
283
|
+
if (name in secrets)
|
|
284
|
+
return refuse(`"${name}" is both a secret and a setting.`);
|
|
285
|
+
if (SECRET_LIKE_NAME.test(name)) {
|
|
286
|
+
return refuse(`the setting "${name}" looks like a secret. Put it under "secrets".`);
|
|
287
|
+
}
|
|
288
|
+
if (!isRecord(raw))
|
|
289
|
+
return refuse(`the setting "${name}" must have a label.`);
|
|
290
|
+
const extra = Object.keys(raw).find((key) => !SETTING_KEYS.includes(key));
|
|
291
|
+
if (extra !== undefined)
|
|
292
|
+
return refuse(`the setting "${name}" cannot say "${extra}".`);
|
|
293
|
+
const { label, help, default: value } = raw;
|
|
294
|
+
if (typeof label !== 'string' || label.trim() === '' || label.length > SETTING_LABEL_MAX) {
|
|
295
|
+
return refuse(`the setting "${name}" needs a label of 1 to ${SETTING_LABEL_MAX} characters.`);
|
|
296
|
+
}
|
|
297
|
+
if (help !== undefined && (typeof help !== 'string' || help.length > SETTING_HELP_MAX)) {
|
|
298
|
+
return refuse(`the help of the setting "${name}" must be at most ${SETTING_HELP_MAX} characters.`);
|
|
299
|
+
}
|
|
300
|
+
if (value !== undefined && (typeof value !== 'string' || value.length > SETTING_DEFAULT_MAX)) {
|
|
301
|
+
return refuse(`the default of the setting "${name}" must be text of at most ${SETTING_DEFAULT_MAX} characters.`);
|
|
302
|
+
}
|
|
303
|
+
settings[name] = {
|
|
304
|
+
label,
|
|
305
|
+
...(help !== undefined ? { help } : {}),
|
|
306
|
+
...(value !== undefined ? { default: value } : {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
manifest: { v: 1, runtime, modules: clone(modules), secrets, settings },
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** A v1 manifest read as v2: a generated secret is `generate`, a held one an `ask`, a setting a `default` or an `ask`. */
|
|
315
|
+
function manifestV1AsV2(v1) {
|
|
316
|
+
const values = {};
|
|
317
|
+
for (const [name, kind] of Object.entries(v1.secrets)) {
|
|
318
|
+
const label = `${name.charAt(0)}${name.slice(1).toLowerCase().replaceAll('_', ' ')}`;
|
|
319
|
+
values[name] =
|
|
320
|
+
kind === 'generated'
|
|
321
|
+
? { source: 'generate', kind: 'token' }
|
|
322
|
+
: {
|
|
323
|
+
source: 'ask',
|
|
324
|
+
label,
|
|
325
|
+
help: `Your company types in ${label}.`,
|
|
326
|
+
secret: true,
|
|
327
|
+
required: true,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
for (const [name, setting] of Object.entries(v1.settings)) {
|
|
331
|
+
values[name] =
|
|
332
|
+
setting.default !== undefined
|
|
333
|
+
? {
|
|
334
|
+
source: 'default',
|
|
335
|
+
value: setting.default,
|
|
336
|
+
description: setting.help ?? setting.label,
|
|
337
|
+
label: setting.label,
|
|
338
|
+
}
|
|
339
|
+
: { source: 'ask', label: setting.label, help: setting.help ?? setting.label };
|
|
340
|
+
}
|
|
341
|
+
return { v: 2, runtime: v1.runtime, modules: v1.modules, values };
|
|
342
|
+
}
|
|
343
|
+
const MANIFEST_V2_KEYS = ['v', 'runtime', 'modules', 'values', 'access'];
|
|
344
|
+
const VALUE_KEYS = {
|
|
345
|
+
generate: ['source', 'kind'],
|
|
346
|
+
reference: ['source', 'to', 'secret'],
|
|
347
|
+
default: ['source', 'value', 'description', 'label', 'labelKey'],
|
|
348
|
+
ask: [
|
|
349
|
+
'source',
|
|
350
|
+
'label',
|
|
351
|
+
'help',
|
|
352
|
+
'labelKey',
|
|
353
|
+
'helpKey',
|
|
354
|
+
'required',
|
|
355
|
+
'secret',
|
|
356
|
+
'default',
|
|
357
|
+
'choices',
|
|
358
|
+
'multiple',
|
|
359
|
+
'pattern',
|
|
360
|
+
'max',
|
|
361
|
+
'into',
|
|
362
|
+
],
|
|
363
|
+
};
|
|
364
|
+
const GENERATE_KINDS = ['token', 'hex', 'uuid'];
|
|
365
|
+
const OPTION_KEYS = ['value', 'label', 'help', 'labelKey'];
|
|
366
|
+
const ASK_ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
367
|
+
const COPY_KEY = /^[a-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/;
|
|
368
|
+
const REFERENCE = /^[a-z][a-z0-9-]*\.[A-Za-z][A-Za-z0-9-]*$/;
|
|
369
|
+
const HOST = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
370
|
+
const HOST_MAX = 20;
|
|
371
|
+
const WHY_MAX = 200;
|
|
372
|
+
const CHOICES_MAX = 50;
|
|
373
|
+
const ASKABLE_KINDS = ['text', 'longtext', 'path', 'choice', 'list'];
|
|
374
|
+
const isText = (value, max) => typeof value === 'string' && value.trim() !== '' && value.length <= max;
|
|
375
|
+
/** A plain sentence when `copy` is not a good label, help or copy key, else null. */
|
|
376
|
+
function copyProblem(name, raw, labelRequired) {
|
|
377
|
+
const { label, help, labelKey, helpKey } = raw;
|
|
378
|
+
if (labelRequired
|
|
379
|
+
? !isText(label, SETTING_LABEL_MAX)
|
|
380
|
+
: label !== undefined && !isText(label, SETTING_LABEL_MAX)) {
|
|
381
|
+
return `"${name}" needs a label of 1 to ${SETTING_LABEL_MAX} characters`;
|
|
382
|
+
}
|
|
383
|
+
if (help !== undefined && !isText(help, SETTING_HELP_MAX)) {
|
|
384
|
+
return `the help of "${name}" must be 1 to ${SETTING_HELP_MAX} characters`;
|
|
385
|
+
}
|
|
386
|
+
for (const [what, key] of [
|
|
387
|
+
['labelKey', labelKey],
|
|
388
|
+
['helpKey', helpKey],
|
|
389
|
+
]) {
|
|
390
|
+
if (key !== undefined && (typeof key !== 'string' || !COPY_KEY.test(key))) {
|
|
391
|
+
return `"${name}": ${what} must be a copy key such as "apps.candor.registration.label"`;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
/** A pattern longer than this is refused. */
|
|
397
|
+
const PATTERN_MAX = 200;
|
|
398
|
+
/** A group that has a repeat inside it and is repeated itself, such as `(a+)+`, `(a*)*` or `(a|b+){2,}`. */
|
|
399
|
+
const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*(?:[+*]|\{\d*,\d*\})(?:[^()\\]|\\.)*\)(?:[+*]|\{\d*,\d*\})/;
|
|
400
|
+
/** A plain sentence when the `ask` `name` is wrong, else null. Fills `ask` with the checked value. */
|
|
401
|
+
function askProblem(name, raw) {
|
|
402
|
+
const at = `the question "${name}"`;
|
|
403
|
+
const bad = copyProblem(name, raw, true);
|
|
404
|
+
if (bad)
|
|
405
|
+
return bad;
|
|
406
|
+
if (typeof raw.help !== 'string')
|
|
407
|
+
return `${at} needs a help line in plain words`;
|
|
408
|
+
for (const key of ['required', 'secret', 'multiple']) {
|
|
409
|
+
if (raw[key] !== undefined && typeof raw[key] !== 'boolean')
|
|
410
|
+
return `${at}: "${key}" must be true or false`;
|
|
411
|
+
}
|
|
412
|
+
if (raw.pattern !== undefined) {
|
|
413
|
+
const text = String(raw.pattern);
|
|
414
|
+
if (text.length > PATTERN_MAX) {
|
|
415
|
+
return `${at}: "pattern" is longer than ${PATTERN_MAX} characters; write a shorter one`;
|
|
416
|
+
}
|
|
417
|
+
if (NESTED_QUANTIFIER.test(text)) {
|
|
418
|
+
return `${at}: "pattern" repeats a group that already repeats (like "(a+)+"), which can hang the page; write it without the nested repeat`;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
new RegExp(text, 'u');
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return `${at}: "pattern" is not a valid pattern`;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (raw.max !== undefined && (!Number.isInteger(raw.max) || raw.max < 1)) {
|
|
428
|
+
return `${at}: "max" must be a whole number of 1 or more`;
|
|
429
|
+
}
|
|
430
|
+
const choices = raw.choices;
|
|
431
|
+
if (choices !== undefined) {
|
|
432
|
+
if (!Array.isArray(choices) || choices.length === 0 || choices.length > CHOICES_MAX) {
|
|
433
|
+
return `${at}: "choices" must list 1 to ${CHOICES_MAX} options`;
|
|
434
|
+
}
|
|
435
|
+
const seen = new Set();
|
|
436
|
+
for (const option of choices) {
|
|
437
|
+
if (!isRecord(option))
|
|
438
|
+
return `${at}: each choice needs a value and a label`;
|
|
439
|
+
const extra = Object.keys(option).find((k) => !OPTION_KEYS.includes(k));
|
|
440
|
+
if (extra !== undefined)
|
|
441
|
+
return `${at}: a choice cannot say "${extra}"`;
|
|
442
|
+
if (!isText(option.value, 200) || seen.has(option.value)) {
|
|
443
|
+
return `${at}: every choice needs its own value`;
|
|
444
|
+
}
|
|
445
|
+
seen.add(option.value);
|
|
446
|
+
const optionBad = copyProblem(option.value, option, true);
|
|
447
|
+
if (optionBad)
|
|
448
|
+
return `${at}: ${optionBad}`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (raw.multiple === true && choices === undefined)
|
|
452
|
+
return `${at}: a list needs "choices"`;
|
|
453
|
+
if (raw.secret === true) {
|
|
454
|
+
if (raw.default !== undefined || choices !== undefined || raw.into !== undefined) {
|
|
455
|
+
return `${at} is a secret: it cannot have a default, choices, or go into a module, because the file is not private`;
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
const value = raw.default;
|
|
460
|
+
if (value !== undefined) {
|
|
461
|
+
const items = raw.multiple === true ? value : [value];
|
|
462
|
+
if ((raw.multiple === true && !Array.isArray(value)) ||
|
|
463
|
+
!Array.isArray(items) ||
|
|
464
|
+
!items.every((item) => typeof item === 'string' && item.length <= SETTING_DEFAULT_MAX)) {
|
|
465
|
+
return `${at}: the default must be ${raw.multiple === true ? 'a list of text' : 'text'}`;
|
|
466
|
+
}
|
|
467
|
+
if (raw.required === true && items.every((item) => item === '')) {
|
|
468
|
+
return `${at} is required, so its default cannot be empty`;
|
|
469
|
+
}
|
|
470
|
+
const allowed = choices?.map((c) => c.value);
|
|
471
|
+
for (const item of items) {
|
|
472
|
+
if (allowed && !allowed.includes(item))
|
|
473
|
+
return `${at}: the default "${item}" is not one of its choices`;
|
|
474
|
+
if (typeof raw.pattern === 'string' &&
|
|
475
|
+
item !== '' &&
|
|
476
|
+
!new RegExp(raw.pattern, 'u').test(item)) {
|
|
477
|
+
return `${at}: the default "${item}" is not written the way it must be`;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
/** A plain sentence when an `into` binding is wrong, else null. */
|
|
484
|
+
function intoProblem(name, raw, modules) {
|
|
485
|
+
const into = raw.into;
|
|
486
|
+
if (!isRecord(into) || typeof into.module !== 'string' || typeof into.field !== 'string') {
|
|
487
|
+
return `the question "${name}": "into" must name a module and a field`;
|
|
488
|
+
}
|
|
489
|
+
const extra = Object.keys(into).find((k) => k !== 'module' && k !== 'field');
|
|
490
|
+
if (extra !== undefined)
|
|
491
|
+
return `the question "${name}": "into" cannot say "${extra}"`;
|
|
492
|
+
if (!Object.hasOwn(modules, into.module)) {
|
|
493
|
+
return `the question "${name}" fills the module "${into.module}", which this app has not turned on`;
|
|
494
|
+
}
|
|
495
|
+
const field = byId.get(into.module)?.view.fields.find((f) => f.key === into.field);
|
|
496
|
+
if (!field)
|
|
497
|
+
return `the question "${name}": the module "${into.module}" has no field "${into.field}"`;
|
|
498
|
+
if (!ASKABLE_KINDS.includes(field.kind)) {
|
|
499
|
+
return `the question "${name}": "${field.label}" cannot be asked as a question`;
|
|
500
|
+
}
|
|
501
|
+
if ((field.kind === 'list') !== (raw.multiple === true)) {
|
|
502
|
+
return `the question "${name}": "multiple" must be ${field.kind === 'list' ? 'true' : 'left out'} for "${field.label}"`;
|
|
503
|
+
}
|
|
504
|
+
const options = raw.choices;
|
|
505
|
+
if (field.choices && options) {
|
|
506
|
+
const stray = options.find((o) => !field.choices?.some((c) => c.value === o.value));
|
|
507
|
+
if (stray)
|
|
508
|
+
return `the question "${name}": "${stray.value}" is not something "${field.label}" accepts`;
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
function parseManifestV2(json) {
|
|
513
|
+
const refuse = (message) => ({
|
|
514
|
+
ok: false,
|
|
515
|
+
message: `company-app.json: ${message}`,
|
|
516
|
+
});
|
|
517
|
+
const unknown = Object.keys(json).find((key) => !MANIFEST_V2_KEYS.includes(key));
|
|
518
|
+
if (unknown !== undefined)
|
|
519
|
+
return refuse(`"${unknown}" is not something the file can say.`);
|
|
520
|
+
const runtime = json.runtime;
|
|
521
|
+
if (runtime !== 'server' && runtime !== 'worker') {
|
|
522
|
+
return refuse('"runtime" must be "worker" (a Worker app) or "server" (an app that runs on a server).');
|
|
523
|
+
}
|
|
524
|
+
const modules = json.modules ?? {};
|
|
525
|
+
if (!isRecord(modules))
|
|
526
|
+
return refuse('"modules" must be an object of module names.');
|
|
527
|
+
const rawValues = json.values ?? {};
|
|
528
|
+
if (!isRecord(rawValues))
|
|
529
|
+
return refuse('"values" must be an object of value names.');
|
|
530
|
+
const asked = new Set();
|
|
531
|
+
const values = {};
|
|
532
|
+
const probe = clone(modules);
|
|
533
|
+
for (const [name, raw] of Object.entries(rawValues)) {
|
|
534
|
+
if (!isRecord(raw))
|
|
535
|
+
return refuse(`"${name}" must say where its value comes from ("source").`);
|
|
536
|
+
const source = raw.source;
|
|
537
|
+
if (typeof source !== 'string' || !(source in VALUE_KEYS)) {
|
|
538
|
+
return refuse(`"${name}": "source" must be "generate", "reference", "default" or "ask".`);
|
|
539
|
+
}
|
|
540
|
+
const extra = Object.keys(raw).find((k) => !VALUE_KEYS[source]?.includes(k));
|
|
541
|
+
if (extra !== undefined)
|
|
542
|
+
return refuse(`"${name}" (${source}) cannot say "${extra}".`);
|
|
543
|
+
const into = source === 'ask' && raw.into !== undefined;
|
|
544
|
+
if (into) {
|
|
545
|
+
if (!ASK_ID.test(name)) {
|
|
546
|
+
return refuse(`the question "${name}" must be named in lowercase letters, digits and "-".`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
const bad = nameProblem('value', name);
|
|
551
|
+
if (bad)
|
|
552
|
+
return refuse(`${bad}.`);
|
|
553
|
+
}
|
|
554
|
+
const secretLike = !into && SECRET_LIKE_NAME.test(name);
|
|
555
|
+
let value;
|
|
556
|
+
if (source === 'generate') {
|
|
557
|
+
if (!GENERATE_KINDS.includes(raw.kind)) {
|
|
558
|
+
return refuse(`"${name}": "kind" must be "token", "hex" or "uuid".`);
|
|
559
|
+
}
|
|
560
|
+
value = { source, kind: raw.kind };
|
|
561
|
+
}
|
|
562
|
+
else if (source === 'reference') {
|
|
563
|
+
if (typeof raw.to !== 'string' || !REFERENCE.test(raw.to)) {
|
|
564
|
+
return refuse(`"${name}": "to" must name an output, such as "database.address".`);
|
|
565
|
+
}
|
|
566
|
+
const owner = raw.to.slice(0, raw.to.indexOf('.'));
|
|
567
|
+
if (!Object.hasOwn(modules, owner) && owner !== 'app' && owner !== 'company') {
|
|
568
|
+
return refuse(`"${name}" reads from "${owner}", which this app has not turned on.`);
|
|
569
|
+
}
|
|
570
|
+
const unknownOutput = referenceProblem(raw.to);
|
|
571
|
+
if (unknownOutput)
|
|
572
|
+
return refuse(`"${name}" reads "${raw.to}": ${unknownOutput}`);
|
|
573
|
+
if (raw.secret !== undefined && typeof raw.secret !== 'boolean') {
|
|
574
|
+
return refuse(`"${name}": "secret" must be true or false.`);
|
|
575
|
+
}
|
|
576
|
+
if (secretLike && raw.secret !== true) {
|
|
577
|
+
return refuse(`the value "${name}" looks like a secret. Say "secret": true.`);
|
|
578
|
+
}
|
|
579
|
+
if (SECRET_OUTPUTS.has(raw.to) && raw.secret !== true) {
|
|
580
|
+
return refuse(`"${name}" reads "${raw.to}", which is a secret. Say "secret": true.`);
|
|
581
|
+
}
|
|
582
|
+
value = { source, to: raw.to, ...(raw.secret === true ? { secret: true } : {}) };
|
|
583
|
+
}
|
|
584
|
+
else if (source === 'default') {
|
|
585
|
+
if (secretLike) {
|
|
586
|
+
return refuse(`the value "${name}" looks like a secret, so it cannot have a default in this file.`);
|
|
587
|
+
}
|
|
588
|
+
if (typeof raw.value !== 'string' || raw.value.length > SETTING_DEFAULT_MAX) {
|
|
589
|
+
return refuse(`the default of "${name}" must be text of at most ${SETTING_DEFAULT_MAX} characters.`);
|
|
590
|
+
}
|
|
591
|
+
if (!isText(raw.description, SETTING_HELP_MAX)) {
|
|
592
|
+
return refuse(`"${name}" needs a description of 1 to ${SETTING_HELP_MAX} characters, in plain words.`);
|
|
593
|
+
}
|
|
594
|
+
const bad = copyProblem(name, raw, false);
|
|
595
|
+
if (bad)
|
|
596
|
+
return refuse(`${bad}.`);
|
|
597
|
+
value = {
|
|
598
|
+
source,
|
|
599
|
+
value: raw.value,
|
|
600
|
+
description: raw.description,
|
|
601
|
+
...(raw.label !== undefined ? { label: raw.label } : {}),
|
|
602
|
+
...(raw.labelKey !== undefined ? { labelKey: raw.labelKey } : {}),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
const bad = askProblem(name, raw);
|
|
607
|
+
if (bad)
|
|
608
|
+
return refuse(`${bad}.`);
|
|
609
|
+
if (secretLike && raw.secret !== true) {
|
|
610
|
+
return refuse(`the value "${name}" looks like a secret. Say "secret": true.`);
|
|
611
|
+
}
|
|
612
|
+
if (into) {
|
|
613
|
+
const badInto = intoProblem(name, raw, modules);
|
|
614
|
+
if (badInto)
|
|
615
|
+
return refuse(`${badInto}.`);
|
|
616
|
+
const target = raw.into;
|
|
617
|
+
const slot = `${target.module}.${target.field}`;
|
|
618
|
+
if (asked.has(slot))
|
|
619
|
+
return refuse(`two questions fill "${slot}".`);
|
|
620
|
+
asked.add(slot);
|
|
621
|
+
const sample = (raw.default ??
|
|
622
|
+
raw.choices?.[0]?.value);
|
|
623
|
+
const mod = probe[target.module];
|
|
624
|
+
if (mod && mod[target.field] === undefined && sample !== undefined) {
|
|
625
|
+
mod[target.field] =
|
|
626
|
+
raw.multiple === true ? (Array.isArray(sample) ? sample : [sample]) : sample;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
value = clone(raw);
|
|
630
|
+
}
|
|
631
|
+
values[name] = value;
|
|
632
|
+
}
|
|
633
|
+
const refused = checkModules(probe, { runtime });
|
|
634
|
+
if (refused)
|
|
635
|
+
return refuse(refused.message);
|
|
636
|
+
for (const [id, fields] of Object.entries(modules)) {
|
|
637
|
+
const view = byId.get(id)?.view;
|
|
638
|
+
const secret = view?.fields.find((f) => f.kind === 'secret' && !isEmpty(fields[f.key]));
|
|
639
|
+
if (view && secret) {
|
|
640
|
+
return refuse(`"${secret.label}" (${view.name}) is a secret. Your company types it in when it sets up the app; it never goes in this file.`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// `access` is optional and advisory: absent means the app may connect anywhere, and the hosts listed
|
|
644
|
+
// are a recommendation shown to the company, never enforced.
|
|
645
|
+
const rawAccess = json.access ?? {};
|
|
646
|
+
if (!isRecord(rawAccess))
|
|
647
|
+
return refuse('"access" must be an object.');
|
|
648
|
+
const extraAccess = Object.keys(rawAccess).find((k) => k !== 'hosts');
|
|
649
|
+
if (extraAccess !== undefined)
|
|
650
|
+
return refuse(`"access" cannot say "${extraAccess}".`);
|
|
651
|
+
const rawHosts = rawAccess.hosts ?? [];
|
|
652
|
+
if (!Array.isArray(rawHosts) || rawHosts.length > HOST_MAX) {
|
|
653
|
+
return refuse(`"access.hosts" must list at most ${HOST_MAX} places the app recommends reaching.`);
|
|
654
|
+
}
|
|
655
|
+
const seen = new Set();
|
|
656
|
+
const hosts = [];
|
|
657
|
+
for (const entry of rawHosts) {
|
|
658
|
+
if (!isRecord(entry) || Object.keys(entry).some((k) => k !== 'host' && k !== 'why')) {
|
|
659
|
+
return refuse('every place in "access.hosts" needs a "host" and a "why" and nothing else.');
|
|
660
|
+
}
|
|
661
|
+
const { host, why } = entry;
|
|
662
|
+
if (typeof host !== 'string' || !HOST.test(host)) {
|
|
663
|
+
return refuse(`"${String(host)}" is not a host name. Write it like "api.example.com" or "*.example.com", with no "https://", port or path.`);
|
|
664
|
+
}
|
|
665
|
+
if (seen.has(host))
|
|
666
|
+
return refuse(`"${host}" is listed twice.`);
|
|
667
|
+
seen.add(host);
|
|
668
|
+
if (!isText(why, WHY_MAX)) {
|
|
669
|
+
return refuse(`say in plain words why the app reaches "${host}" (1 to ${WHY_MAX} characters).`);
|
|
670
|
+
}
|
|
671
|
+
hosts.push({ host, why });
|
|
672
|
+
}
|
|
673
|
+
const manifest = { v: 2, runtime, modules: clone(modules), values };
|
|
674
|
+
if (json.access !== undefined)
|
|
675
|
+
manifest.access = { hosts };
|
|
676
|
+
return { ok: true, manifest };
|
|
677
|
+
}
|
|
678
|
+
/** `company-app.json` (v1 or v2) read and checked: what the app's code asks for. */
|
|
679
|
+
export function parseAppManifest(json) {
|
|
680
|
+
if (!isRecord(json))
|
|
681
|
+
return { ok: false, message: 'company-app.json: the file must hold one JSON object.' };
|
|
682
|
+
if (json.v === 1) {
|
|
683
|
+
const read = parseManifestV1(json);
|
|
684
|
+
return read.ok ? { ok: true, manifest: manifestV1AsV2(read.manifest), from: 1 } : read;
|
|
685
|
+
}
|
|
686
|
+
if (json.v === 2) {
|
|
687
|
+
const read = parseManifestV2(json);
|
|
688
|
+
return read.ok ? { ok: true, manifest: read.manifest, from: 2 } : read;
|
|
689
|
+
}
|
|
690
|
+
return { ok: false, message: 'company-app.json: "v" must be 1 or 2.' };
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* The company's answers checked against the manifest: a required question left empty blocks the install,
|
|
694
|
+
* and every answer must fit its question. Questions with a default may be left out of `answers`.
|
|
695
|
+
*/
|
|
696
|
+
export function checkManifestAnswers(manifest, answers) {
|
|
697
|
+
const problems = [];
|
|
698
|
+
for (const key of Object.keys(answers)) {
|
|
699
|
+
if (manifest.values[key]?.source !== 'ask') {
|
|
700
|
+
problems.push({ key, message: `"${key}" is not something this app asks.` });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const out = {
|
|
704
|
+
modules: clone(manifest.modules),
|
|
705
|
+
plain: {},
|
|
706
|
+
secrets: {},
|
|
707
|
+
generate: {},
|
|
708
|
+
references: {},
|
|
709
|
+
};
|
|
710
|
+
for (const [key, value] of Object.entries(manifest.values)) {
|
|
711
|
+
if (value.source === 'generate')
|
|
712
|
+
out.generate[key] = value.kind;
|
|
713
|
+
else if (value.source === 'reference') {
|
|
714
|
+
out.references[key] = { to: value.to, secret: value.secret === true };
|
|
715
|
+
}
|
|
716
|
+
else if (value.source === 'default')
|
|
717
|
+
out.plain[key] = value.value;
|
|
718
|
+
else {
|
|
719
|
+
const given = answers[key] ?? value.default;
|
|
720
|
+
const name = `"${value.label}"`;
|
|
721
|
+
const list = Array.isArray(given)
|
|
722
|
+
? given
|
|
723
|
+
: given === undefined || given === ''
|
|
724
|
+
? []
|
|
725
|
+
: [given];
|
|
726
|
+
const bad = (() => {
|
|
727
|
+
if (list.length === 0 || list.every((item) => item === '')) {
|
|
728
|
+
return value.required ? `${name} is needed to install.` : null;
|
|
729
|
+
}
|
|
730
|
+
if (value.multiple ? !Array.isArray(given) : typeof given !== 'string') {
|
|
731
|
+
return `${name} must be ${value.multiple ? 'a list' : 'one answer'}.`;
|
|
732
|
+
}
|
|
733
|
+
const longest = value.max;
|
|
734
|
+
if (longest !== undefined && list.some((item) => item.length > longest)) {
|
|
735
|
+
return `${name} may be at most ${longest} characters.`;
|
|
736
|
+
}
|
|
737
|
+
const pattern = value.pattern === undefined ? null : new RegExp(value.pattern, 'u');
|
|
738
|
+
for (const item of list) {
|
|
739
|
+
if (typeof item !== 'string')
|
|
740
|
+
return `${name} must be text.`;
|
|
741
|
+
if (value.choices && !value.choices.some((c) => c.value === item)) {
|
|
742
|
+
return `${name} cannot be "${item}". Pick from the choices shown.`;
|
|
743
|
+
}
|
|
744
|
+
if (pattern && !pattern.test(item))
|
|
745
|
+
return `${name} is not written the way it must be.`;
|
|
746
|
+
}
|
|
747
|
+
if (new Set(list).size !== list.length)
|
|
748
|
+
return `${name} names something twice.`;
|
|
749
|
+
return null;
|
|
750
|
+
})();
|
|
751
|
+
if (bad) {
|
|
752
|
+
problems.push({ key, message: bad });
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (list.length === 0)
|
|
756
|
+
continue;
|
|
757
|
+
if (value.into) {
|
|
758
|
+
const target = out.modules[value.into.module];
|
|
759
|
+
if (target)
|
|
760
|
+
target[value.into.field] = value.multiple ? list : list[0];
|
|
761
|
+
}
|
|
762
|
+
else if (value.secret)
|
|
763
|
+
out.secrets[key] = list.join(',');
|
|
764
|
+
else
|
|
765
|
+
out.plain[key] = list.join(',');
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (problems.length === 0) {
|
|
769
|
+
const refused = checkModules(out.modules, { runtime: manifest.runtime });
|
|
770
|
+
if (refused)
|
|
771
|
+
problems.push({ key: null, message: refused.message });
|
|
772
|
+
}
|
|
773
|
+
return problems.length > 0 ? { ok: false, problems } : { ok: true, ...out };
|
|
774
|
+
}
|