@wtfalch/foundry 0.76.1 → 0.77.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.
Files changed (125) hide show
  1. package/dist/app-canary-worker.d.ts +35 -0
  2. package/dist/app-canary-worker.js +56 -0
  3. package/dist/audit/connector.d.ts +6 -2
  4. package/dist/audit/connector.js +3 -2
  5. package/dist/audit/evidence.d.ts +4 -0
  6. package/dist/audit/evidence.js +14 -2
  7. package/dist/audit/host-store.d.ts +58 -0
  8. package/dist/audit/host-store.js +147 -0
  9. package/dist/audit/host.d.ts +53 -0
  10. package/dist/audit/host.js +294 -0
  11. package/dist/audit/index.d.ts +2 -0
  12. package/dist/audit/index.js +2 -0
  13. package/dist/audit/registry.d.ts +14 -0
  14. package/dist/audit/sealer-role.d.ts +12 -0
  15. package/dist/audit/sealer-role.js +26 -2
  16. package/dist/audit/tick.d.ts +10 -2
  17. package/dist/audit/tick.js +15 -2
  18. package/dist/catalogue/copy-en.js +5 -0
  19. package/dist/catalogue/integrations.js +5 -0
  20. package/dist/entry-rules.d.ts +35 -0
  21. package/dist/entry-rules.js +35 -0
  22. package/dist/hosted-label.js +2 -1
  23. package/dist/index.d.ts +11 -3
  24. package/dist/index.js +6 -1
  25. package/dist/migrations/0072_audit_host.sql +46 -0
  26. package/dist/migrations/0074_platform_app_canary.sql +11 -0
  27. package/dist/modules/ai.js +15 -1
  28. package/dist/modules/assistants.js +3 -0
  29. package/dist/modules/email.d.ts +6 -1
  30. package/dist/modules/email.js +88 -5
  31. package/dist/modules/files.js +79 -2
  32. package/dist/modules/index.js +7 -0
  33. package/dist/modules/jobs.d.ts +5 -1
  34. package/dist/modules/jobs.js +74 -4
  35. package/dist/modules/reporting.d.ts +9 -0
  36. package/dist/modules/reporting.js +29 -0
  37. package/dist/modules/sign-in.js +20 -2
  38. package/dist/modules/types.d.ts +2 -0
  39. package/dist/modules/workspaces.js +3 -0
  40. package/dist/org.d.ts +114 -13
  41. package/dist/org.js +233 -20
  42. package/dist/plan/ai-boule-key-apply.d.ts +65 -0
  43. package/dist/plan/ai-boule-key-apply.js +169 -0
  44. package/dist/plan/ai-boule-key.d.ts +89 -0
  45. package/dist/plan/ai-boule-key.js +195 -0
  46. package/dist/plan/analytics.d.ts +8 -1
  47. package/dist/plan/analytics.js +8 -1
  48. package/dist/plan/app-canary-apply.d.ts +49 -0
  49. package/dist/plan/app-canary-apply.js +232 -0
  50. package/dist/plan/app-canary.d.ts +44 -0
  51. package/dist/plan/app-canary.js +110 -0
  52. package/dist/plan/blue-green-apply.d.ts +6 -2
  53. package/dist/plan/blue-green-apply.js +14 -3
  54. package/dist/plan/broker-entries.js +20 -5
  55. package/dist/plan/company-backups.js +2 -0
  56. package/dist/plan/diff.d.ts +22 -0
  57. package/dist/plan/diff.js +231 -18
  58. package/dist/plan/entry-files-key.d.ts +39 -3
  59. package/dist/plan/entry-files-key.js +103 -6
  60. package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
  61. package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
  62. package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
  63. package/dist/plan/entry-jobs-boule-key.js +141 -0
  64. package/dist/plan/entry-jobs.d.ts +2 -0
  65. package/dist/plan/entry-reporting-key.d.ts +36 -0
  66. package/dist/plan/entry-reporting-key.js +136 -0
  67. package/dist/plan/executor.d.ts +108 -1
  68. package/dist/plan/executor.js +669 -23
  69. package/dist/plan/files-boule-key-apply.d.ts +73 -0
  70. package/dist/plan/files-boule-key-apply.js +223 -0
  71. package/dist/plan/files-boule-key.d.ts +92 -0
  72. package/dist/plan/files-boule-key.js +175 -0
  73. package/dist/plan/hostnames.js +4 -0
  74. package/dist/plan/index.d.ts +4 -0
  75. package/dist/plan/index.js +2 -0
  76. package/dist/plan/jobs-scheduler-http.d.ts +13 -0
  77. package/dist/plan/jobs-scheduler-http.js +39 -0
  78. package/dist/plan/live.d.ts +1 -1
  79. package/dist/plan/locked-hostnames.d.ts +1 -1
  80. package/dist/plan/locked-hostnames.js +4 -1
  81. package/dist/plan/login-host.js +2 -2
  82. package/dist/plan/org-names.js +3 -0
  83. package/dist/plan/own-diff.js +3 -2
  84. package/dist/plan/own-jobs-route.d.ts +5 -2
  85. package/dist/plan/own-jobs-route.js +6 -2
  86. package/dist/plan/own-limits.d.ts +6 -0
  87. package/dist/plan/own-limits.js +1 -0
  88. package/dist/plan/plan-apply.d.ts +24 -0
  89. package/dist/plan/plan-apply.js +39 -4
  90. package/dist/plan/platform-diff.d.ts +2 -0
  91. package/dist/plan/platform-diff.js +15 -1
  92. package/dist/plan/platform-providers.d.ts +9 -0
  93. package/dist/plan/platform-providers.js +24 -0
  94. package/dist/plan/providers-readers.js +25 -1
  95. package/dist/plan/registered-app-env.d.ts +31 -0
  96. package/dist/plan/registered-app-env.js +63 -0
  97. package/dist/plan/registered-app.d.ts +121 -0
  98. package/dist/plan/registered-app.js +262 -0
  99. package/dist/plan/release-host.js +2 -1
  100. package/dist/plan/reporting-boule.d.ts +18 -0
  101. package/dist/plan/reporting-boule.js +44 -0
  102. package/dist/plan/reporting.d.ts +90 -0
  103. package/dist/plan/reporting.js +135 -0
  104. package/dist/plan/stage-env.js +1 -0
  105. package/dist/plan/stage-isolation.d.ts +3 -1
  106. package/dist/plan/stage-isolation.js +20 -2
  107. package/dist/plan/stage-services.d.ts +3 -1
  108. package/dist/plan/stage-services.js +8 -1
  109. package/dist/plan/stage-sink.d.ts +2 -2
  110. package/dist/plan/stage-sink.js +2 -2
  111. package/dist/plan/workspace-client.d.ts +1 -1
  112. package/dist/plan/workspace-client.js +2 -1
  113. package/dist/plan/workspace-sign-in.d.ts +1 -1
  114. package/dist/plan/workspace-sign-in.js +39 -7
  115. package/dist/platform.d.ts +25 -0
  116. package/dist/platform.js +20 -0
  117. package/dist/service-labels.d.ts +8 -0
  118. package/dist/service-labels.js +8 -0
  119. package/dist/slug.d.ts +1 -1
  120. package/dist/slug.js +5 -2
  121. package/dist/store/postgres.js +13 -0
  122. package/dist/store/schema.d.ts +485 -2
  123. package/dist/store/schema.js +38 -1
  124. package/dist/worker-app-config.js +2 -2
  125. package/package.json +1 -1
@@ -0,0 +1,46 @@
1
+ -- @wtfalch/foundry: the audit host (foundry#1110). Three new tables, nothing existing touched, so old and new
2
+ -- releases run side by side. Mirrors src/store/schema.ts's `foundryAuditHostKeys`,
3
+ -- `foundryAuditLedgerRegistrations` and `foundryAuditSealResults`.
4
+ --
5
+ -- A company's audit host key: only its sha256 is kept; the key itself goes to the company's Boule once.
6
+ create table if not exists foundry_audit_host_keys (
7
+ org_id text primary key,
8
+ key_sha256 text not null unique,
9
+ created_at timestamptz not null default now()
10
+ );
11
+
12
+ -- A ledger a company's Boule registered (`PUT /v1/audit/ledgers/:app`): where its live database is and the
13
+ -- sealing role it is opened as. The password is not here: it is in the host's held keys at `password_ref`.
14
+ create table if not exists foundry_audit_ledger_registrations (
15
+ org_id text not null,
16
+ entry_key text not null,
17
+ copy text not null,
18
+ db_host text not null,
19
+ db_port integer not null,
20
+ db_name text not null,
21
+ db_user text not null,
22
+ sslmode text,
23
+ password_ref text not null,
24
+ created_at timestamptz not null default now(),
25
+ updated_at timestamptz not null default now(),
26
+ primary key (org_id, entry_key)
27
+ );
28
+
29
+ -- Each seal tick's result per ledger, kept for 12 hours (`SEAL_RESULTS_KEPT_HOURS`): what `status` shows.
30
+ create table if not exists foundry_audit_seal_results (
31
+ id bigserial primary key,
32
+ org_id text not null,
33
+ entry_key text not null,
34
+ ledger text not null,
35
+ outcome text not null,
36
+ tree_size integer,
37
+ anchored integer not null default 0,
38
+ erasures integer not null default 0,
39
+ reason text,
40
+ at timestamptz not null
41
+ );
42
+
43
+ create index if not exists foundry_audit_seal_results_ledger_idx
44
+ on foundry_audit_seal_results (org_id, entry_key, at desc, id desc);
45
+ create index if not exists foundry_audit_seal_results_at_idx
46
+ on foundry_audit_seal_results (at);
@@ -0,0 +1,11 @@
1
+ -- @wtfalch/foundry: the app-layer canary on the declared platform (foundry#1133).
2
+ -- Mirrors `src/store/schema.ts`'s `foundryDeclaredPlatform` additions.
3
+ -- `app_canary_tag` and `app_canary_sha256` are the pin of the canary Worker
4
+ -- foundry ships (`appCanaryShippedPin()`); both empty means nothing is pinned
5
+ -- yet, and the planner reports that instead of planning the step. Off by
6
+ -- default, like the usage tail Worker.
7
+
8
+ alter table foundry_declared_platform
9
+ add column if not exists app_canary_enabled boolean not null default false,
10
+ add column if not exists app_canary_tag text not null default '',
11
+ add column if not exists app_canary_sha256 text not null default '';
@@ -1,5 +1,19 @@
1
1
  import { AI_ENTRY_PERMISSIONS, AI_WEBHOOK_PATH } from '../entry-rules.js';
2
2
  import { list, setUse, text } from './entry-parts.js';
3
+ /**
4
+ * Plain words for every permission an app's AI key may hold. A `Record` over the permission list, so a
5
+ * permission added to {@link AI_ENTRY_PERMISSIONS} fails the typecheck until it has words here.
6
+ */
7
+ const PERMISSION_WORDS = {
8
+ 'ai.offerings:invoke': {
9
+ label: 'Use the AI tasks and wait for the answer',
10
+ help: 'The app asks an AI task and gets the answer back at once.',
11
+ },
12
+ 'ai.runs:create': {
13
+ label: 'Start AI work that finishes later',
14
+ help: 'The app starts longer AI work and gets the result when it is done.',
15
+ },
16
+ };
3
17
  /** `uses.ai`: the app calls the AI tasks set up for the company. */
4
18
  export const ai = {
5
19
  view: {
@@ -33,7 +47,7 @@ export const ai = {
33
47
  help: 'Leave it empty to allow all of them.',
34
48
  kind: 'list',
35
49
  advanced: true,
36
- choices: AI_ENTRY_PERMISSIONS.map((value) => ({ value, label: value })),
50
+ choices: AI_ENTRY_PERMISSIONS.map((value) => ({ value, ...PERMISSION_WORDS[value] })),
37
51
  },
38
52
  ],
39
53
  adds: [{ name: 'AI tasks', hint: "Run on your company's own AI account." }],
@@ -57,6 +57,9 @@ export const assistants = {
57
57
  if (signIn?.users !== 'customers') {
58
58
  return problem(null, 'Help for AI assistants needs Sign-in for your customers: each customer connects their own assistant. In Sign-in, choose "Your customers". They can sign up themselves or be invited.');
59
59
  }
60
+ if (signIn.team === true) {
61
+ return problem(null, 'Help for AI assistants is not ready yet for an app your team signs in to as well. In Sign-in, turn off "Your team signs in too".');
62
+ }
60
63
  const onboarding = text(values, 'onboardingPath');
61
64
  if (onboarding !== undefined && !isPagePath(onboarding)) {
62
65
  return problem('onboardingPath', 'The page must start with "/", such as /onboarding, with no spaces, "?", "#" or "..".');
@@ -1,3 +1,8 @@
1
1
  import type { AppModule } from './types.js';
2
- /** `uses.mail`: the app sends mail from the company's mail domain. */
2
+ /**
3
+ * `uses.mail`: the app sends mail from the company's mail domain. With every setting at its default the entry
4
+ * holds `uses.mail: true`, as before; otherwise an {@link EntryMail} holding only the settings that differ.
5
+ * The sender name and reply-to go to the app's env (`entrySettingsEnv`); the bounce settings are for the
6
+ * company's mail service.
7
+ */
3
8
  export declare const email: AppModule;
@@ -1,5 +1,11 @@
1
- import { setUse } from './entry-parts.js';
2
- /** `uses.mail`: the app sends mail from the company's mail domain. */
1
+ import { MAIL_BOUNCES_DEFAULT, MAIL_BOUNCES_MAX, MAIL_BOUNCE_ALERT_DEFAULT, MAIL_FROM_NAME, MAIL_FROM_NAME_MAX, MAIL_REPLY_TO, MAIL_REPLY_TO_MAX, PERCENT_MAX, PERCENT_MIN, } from '../entry-rules.js';
2
+ import { problem, setUse, text } from './entry-parts.js';
3
+ /**
4
+ * `uses.mail`: the app sends mail from the company's mail domain. With every setting at its default the entry
5
+ * holds `uses.mail: true`, as before; otherwise an {@link EntryMail} holding only the settings that differ.
6
+ * The sender name and reply-to go to the app's env (`entrySettingsEnv`); the bounce settings are for the
7
+ * company's mail service.
8
+ */
3
9
  export const email = {
4
10
  view: {
5
11
  id: 'email',
@@ -10,9 +16,86 @@ export const email = {
10
16
  runtimes: ['server'],
11
17
  requires: [],
12
18
  availability: 'ready',
13
- fields: [],
19
+ fields: [
20
+ {
21
+ key: 'fromName',
22
+ label: 'Sender name',
23
+ help: "The name people see the email come from. Leave it empty to use your company's name.",
24
+ kind: 'text',
25
+ max: MAIL_FROM_NAME_MAX,
26
+ pattern: MAIL_FROM_NAME.source,
27
+ },
28
+ {
29
+ key: 'replyTo',
30
+ label: 'Replies go to',
31
+ help: 'An email address, such as help@example.com. Leave it empty to send replies to the sending address.',
32
+ kind: 'text',
33
+ max: MAIL_REPLY_TO_MAX,
34
+ pattern: MAIL_REPLY_TO.source,
35
+ },
36
+ {
37
+ key: 'stopAfterBounces',
38
+ label: 'Stop sending to an address after this many bounces',
39
+ help: `An email bounces when it cannot be delivered. From 1 to ${MAIL_BOUNCES_MAX}.`,
40
+ kind: 'number',
41
+ default: MAIL_BOUNCES_DEFAULT,
42
+ min: 1,
43
+ max: MAIL_BOUNCES_MAX,
44
+ },
45
+ {
46
+ key: 'bounceAlertPercent',
47
+ label: 'Email the owners when more than this share of emails bounce',
48
+ help: `In percent of a week's email, from ${PERCENT_MIN} to ${PERCENT_MAX}.`,
49
+ kind: 'number',
50
+ default: MAIL_BOUNCE_ALERT_DEFAULT,
51
+ min: PERCENT_MIN,
52
+ max: PERCENT_MAX,
53
+ },
54
+ ],
14
55
  adds: [{ name: 'Sending email', hint: "From your company's own mail address." }],
15
56
  },
16
- toEntry: (entry, values) => setUse(entry, 'mail', values === null ? undefined : true),
17
- fromEntry: (entry) => (entry.uses?.mail === true ? {} : null),
57
+ check(values) {
58
+ const name = text(values, 'fromName');
59
+ if (name !== undefined && name.trim() !== name) {
60
+ return problem('fromName', '"Sender name" cannot start or end with a space.');
61
+ }
62
+ return null;
63
+ },
64
+ toEntry(entry, values) {
65
+ if (values === null)
66
+ return setUse(entry, 'mail', undefined);
67
+ const next = {};
68
+ const fromName = text(values, 'fromName');
69
+ const replyTo = text(values, 'replyTo');
70
+ if (fromName !== undefined)
71
+ next.fromName = fromName;
72
+ if (replyTo !== undefined)
73
+ next.replyTo = replyTo;
74
+ const bounces = values.stopAfterBounces;
75
+ if (typeof bounces === 'number' && bounces !== MAIL_BOUNCES_DEFAULT) {
76
+ next.stopAfterBounces = bounces;
77
+ }
78
+ const alert = values.bounceAlertPercent;
79
+ if (typeof alert === 'number' && alert !== MAIL_BOUNCE_ALERT_DEFAULT) {
80
+ next.bounceAlertPercent = alert;
81
+ }
82
+ return setUse(entry, 'mail', Object.keys(next).length === 0 ? true : next);
83
+ },
84
+ fromEntry(entry) {
85
+ const used = entry.uses?.mail;
86
+ if (!used)
87
+ return null;
88
+ if (used === true)
89
+ return {};
90
+ const values = {};
91
+ if (used.fromName !== undefined)
92
+ values.fromName = used.fromName;
93
+ if (used.replyTo !== undefined)
94
+ values.replyTo = used.replyTo;
95
+ if (used.stopAfterBounces !== undefined)
96
+ values.stopAfterBounces = used.stopAfterBounces;
97
+ if (used.bounceAlertPercent !== undefined)
98
+ values.bounceAlertPercent = used.bounceAlertPercent;
99
+ return values;
100
+ },
18
101
  };
@@ -1,5 +1,31 @@
1
- import { FILES_COLLECTION_NAME, FILES_ENTRY_PERMISSIONS } from '../entry-rules.js';
1
+ import { FILES_COLLECTION_NAME, FILES_ENTRY_PERMISSIONS, FILES_SHARE_LINK_DAYS, FILES_SHARE_LINK_DAYS_DEFAULT, FILES_SPACE_ALERT_DEFAULT, PERCENT_MAX, PERCENT_MIN, } from '../entry-rules.js';
2
2
  import { list, setUse } from './entry-parts.js';
3
+ /**
4
+ * Plain words for every permission an app's files key may hold. A `Record` over the permission list, so a
5
+ * permission added to {@link FILES_ENTRY_PERMISSIONS} fails the typecheck until it has words here.
6
+ */
7
+ const PERMISSION_WORDS = {
8
+ 'files.collections:read': {
9
+ label: 'See the folders',
10
+ help: 'The app lists its folders and what each one holds.',
11
+ },
12
+ 'files.files:read': {
13
+ label: 'See the files',
14
+ help: 'The app reads the names and details of the files.',
15
+ },
16
+ 'files.files:download': {
17
+ label: 'Open files',
18
+ help: 'The app gets the content of a file to show or send it.',
19
+ },
20
+ 'files.files:upload': { label: 'Add files', help: 'The app saves new files.' },
21
+ 'files.files:replace': {
22
+ label: 'Replace files',
23
+ help: 'The app saves a new version over a file it already has.',
24
+ },
25
+ 'files.files:delete': { label: 'Delete files', help: 'The app removes files.' },
26
+ };
27
+ const NEVER = 'never';
28
+ const dayWords = (days) => (days === 1 ? '1 day' : `${days} days`);
3
29
  /** `uses.files`: the app keeps files (uploads, documents) in folders of its own. */
4
30
  export const files = {
5
31
  view: {
@@ -20,6 +46,37 @@ export const files = {
20
46
  required: true,
21
47
  pattern: FILES_COLLECTION_NAME.source,
22
48
  },
49
+ {
50
+ key: 'shareLinkDays',
51
+ label: 'Share links stop working after',
52
+ help: 'How long a link to a shared file works, unless the person who shares it picks another time.',
53
+ kind: 'choice',
54
+ default: String(FILES_SHARE_LINK_DAYS_DEFAULT),
55
+ choices: [
56
+ ...FILES_SHARE_LINK_DAYS.map((days) => ({ value: String(days), label: dayWords(days) })),
57
+ {
58
+ value: NEVER,
59
+ label: 'Never',
60
+ help: 'A link works until someone stops sharing the file.',
61
+ },
62
+ ],
63
+ },
64
+ {
65
+ key: 'shareLinkPick',
66
+ label: 'People may pick another time when they share',
67
+ help: 'Off: every link stops working after the time above.',
68
+ kind: 'switch',
69
+ default: true,
70
+ },
71
+ {
72
+ key: 'spaceAlertPercent',
73
+ label: 'Email the owners when this much of the space is used',
74
+ help: `In percent, from ${PERCENT_MIN} to ${PERCENT_MAX}.`,
75
+ kind: 'number',
76
+ default: FILES_SPACE_ALERT_DEFAULT,
77
+ min: PERCENT_MIN,
78
+ max: PERCENT_MAX,
79
+ },
23
80
  {
24
81
  key: 'shareHost',
25
82
  label: 'Share links on your own address',
@@ -34,7 +91,7 @@ export const files = {
34
91
  help: 'Leave it empty to allow all of them.',
35
92
  kind: 'list',
36
93
  advanced: true,
37
- choices: FILES_ENTRY_PERMISSIONS.map((value) => ({ value, label: value })),
94
+ choices: FILES_ENTRY_PERMISSIONS.map((value) => ({ value, ...PERMISSION_WORDS[value] })),
38
95
  },
39
96
  ],
40
97
  adds: [{ name: 'File storage', hint: 'Only this app reaches its folders.' }],
@@ -48,6 +105,19 @@ export const files = {
48
105
  next.permissions = permissions;
49
106
  if (values.shareHost === true)
50
107
  next.shareHost = true;
108
+ // A setting at its default is left out, so an entry saved before these settings plans as it did.
109
+ const days = values.shareLinkDays;
110
+ if (days === NEVER)
111
+ next.shareLinkDays = null;
112
+ else if (typeof days === 'string' && days !== String(FILES_SHARE_LINK_DAYS_DEFAULT)) {
113
+ next.shareLinkDays = Number(days);
114
+ }
115
+ if (values.shareLinkPick === false)
116
+ next.shareLinkPick = false;
117
+ const alert = values.spaceAlertPercent;
118
+ if (typeof alert === 'number' && alert !== FILES_SPACE_ALERT_DEFAULT) {
119
+ next.spaceAlertPercent = alert;
120
+ }
51
121
  return setUse(entry, 'files', next);
52
122
  },
53
123
  fromEntry(entry) {
@@ -59,6 +129,13 @@ export const files = {
59
129
  values.permissions = [...used.permissions];
60
130
  if (used.shareHost !== undefined)
61
131
  values.shareHost = used.shareHost;
132
+ if (used.shareLinkDays !== undefined) {
133
+ values.shareLinkDays = used.shareLinkDays === null ? NEVER : String(used.shareLinkDays);
134
+ }
135
+ if (used.shareLinkPick !== undefined)
136
+ values.shareLinkPick = used.shareLinkPick;
137
+ if (used.spaceAlertPercent !== undefined)
138
+ values.spaceAlertPercent = used.spaceAlertPercent;
62
139
  return values;
63
140
  },
64
141
  };
@@ -11,6 +11,7 @@ import { files } from './files.js';
11
11
  import { jobs } from './jobs.js';
12
12
  import { organisations } from './organisations.js';
13
13
  import { SECRET_OUTPUTS, referenceProblem } from './outputs.js';
14
+ import { reporting } from './reporting.js';
14
15
  import { repository } from './repository.js';
15
16
  import { signIn } from './sign-in.js';
16
17
  import { signedRecords } from './signed-records.js';
@@ -30,6 +31,7 @@ export const MODULES = [
30
31
  email,
31
32
  jobs,
32
33
  analytics,
34
+ reporting,
33
35
  ai,
34
36
  organisations,
35
37
  audit,
@@ -65,6 +67,11 @@ function fieldProblem(field, value) {
65
67
  case 'number':
66
68
  if (typeof value !== 'number' || !Number.isFinite(value))
67
69
  return `${name} must be a number.`;
70
+ if (!Number.isInteger(value))
71
+ return `${name} must be a whole number.`;
72
+ if (field.min !== undefined && value < field.min) {
73
+ return `${name} must be at least ${field.min}.`;
74
+ }
68
75
  return field.max !== undefined && value > field.max
69
76
  ? `${name} may be at most ${field.max}.`
70
77
  : null;
@@ -1,3 +1,7 @@
1
1
  import type { AppModule } from './types.js';
2
- /** `uses.jobs`: the app is a project of its own in the jobs scheduler. */
2
+ /**
3
+ * `uses.jobs`: the app is a project of its own in the jobs scheduler. Its settings are for that project: with
4
+ * every one at its default the entry holds `uses.jobs: true`, as before; otherwise an {@link EntryJobs}
5
+ * holding only the settings that differ.
6
+ */
3
7
  export declare const jobs: AppModule;
@@ -1,5 +1,10 @@
1
+ import { JOBS_PAUSE_AFTER_DEFAULT, JOBS_PAUSE_AFTER_MAX, JOBS_RETRIES_DEFAULT, JOBS_RETRIES_MAX, } from '../entry-rules.js';
1
2
  import { setUse } from './entry-parts.js';
2
- /** `uses.jobs`: the app is a project of its own in the jobs scheduler. */
3
+ /**
4
+ * `uses.jobs`: the app is a project of its own in the jobs scheduler. Its settings are for that project: with
5
+ * every one at its default the entry holds `uses.jobs: true`, as before; otherwise an {@link EntryJobs}
6
+ * holding only the settings that differ.
7
+ */
3
8
  export const jobs = {
4
9
  view: {
5
10
  id: 'jobs',
@@ -10,7 +15,40 @@ export const jobs = {
10
15
  runtimes: ['server'],
11
16
  requires: [],
12
17
  availability: 'ready',
13
- fields: [],
18
+ fields: [
19
+ {
20
+ key: 'retries',
21
+ label: 'Tries again when a job fails',
22
+ help: `How many more times we try a job that failed, from 0 to ${JOBS_RETRIES_MAX}.`,
23
+ kind: 'number',
24
+ default: JOBS_RETRIES_DEFAULT,
25
+ min: 0,
26
+ max: JOBS_RETRIES_MAX,
27
+ },
28
+ {
29
+ key: 'alertOwners',
30
+ label: 'Email the owners when a job failed every try',
31
+ kind: 'switch',
32
+ default: true,
33
+ },
34
+ {
35
+ key: 'pauseOnFailures',
36
+ label: 'Pause a job that keeps failing',
37
+ help: 'A paused job waits until someone resumes it on the Jobs page.',
38
+ kind: 'switch',
39
+ default: false,
40
+ },
41
+ {
42
+ key: 'pauseAfterFailures',
43
+ label: 'Pause it after this many failed runs in a row',
44
+ help: `From 1 to ${JOBS_PAUSE_AFTER_MAX}.`,
45
+ kind: 'number',
46
+ default: JOBS_PAUSE_AFTER_DEFAULT,
47
+ min: 1,
48
+ max: JOBS_PAUSE_AFTER_MAX,
49
+ showWhen: { key: 'pauseOnFailures', equals: true },
50
+ },
51
+ ],
14
52
  adds: [
15
53
  {
16
54
  name: 'A scheduler',
@@ -18,6 +56,38 @@ export const jobs = {
18
56
  },
19
57
  ],
20
58
  },
21
- toEntry: (entry, values) => setUse(entry, 'jobs', values === null ? undefined : true),
22
- fromEntry: (entry) => (entry.uses?.jobs === true ? {} : null),
59
+ toEntry(entry, values) {
60
+ if (values === null)
61
+ return setUse(entry, 'jobs', undefined);
62
+ const next = {};
63
+ if (typeof values.retries === 'number' && values.retries !== JOBS_RETRIES_DEFAULT) {
64
+ next.retries = values.retries;
65
+ }
66
+ if (values.alertOwners === false)
67
+ next.alertOwners = false;
68
+ if (values.pauseOnFailures === true) {
69
+ next.pauseAfterFailures =
70
+ typeof values.pauseAfterFailures === 'number'
71
+ ? values.pauseAfterFailures
72
+ : JOBS_PAUSE_AFTER_DEFAULT;
73
+ }
74
+ return setUse(entry, 'jobs', Object.keys(next).length === 0 ? true : next);
75
+ },
76
+ fromEntry(entry) {
77
+ const used = entry.uses?.jobs;
78
+ if (!used)
79
+ return null;
80
+ if (used === true)
81
+ return {};
82
+ const values = {};
83
+ if (used.retries !== undefined)
84
+ values.retries = used.retries;
85
+ if (used.alertOwners !== undefined)
86
+ values.alertOwners = used.alertOwners;
87
+ if (typeof used.pauseAfterFailures === 'number') {
88
+ values.pauseOnFailures = true;
89
+ values.pauseAfterFailures = used.pauseAfterFailures;
90
+ }
91
+ return values;
92
+ },
23
93
  };
@@ -0,0 +1,9 @@
1
+ import type { AppModule } from './types.js';
2
+ /**
3
+ * `uses.reporting` on a Worker app: the app sends its errors to the company's own reporting backend, and Boule
4
+ * shows them on the app's Errors page. Boule installs a site key on each copy of the app (`REPORTING_URL`,
5
+ * `REPORTING_SITE_KEY`; site = the app key, environment `production` for live and `stage` for the workspace);
6
+ * foundry only sets up the backend. One on/off module, no fields. A server app declares `uses.reporting` itself
7
+ * and gets its key from foundry.
8
+ */
9
+ export declare const reporting: AppModule;
@@ -0,0 +1,29 @@
1
+ import { setUse } from './entry-parts.js';
2
+ /**
3
+ * `uses.reporting` on a Worker app: the app sends its errors to the company's own reporting backend, and Boule
4
+ * shows them on the app's Errors page. Boule installs a site key on each copy of the app (`REPORTING_URL`,
5
+ * `REPORTING_SITE_KEY`; site = the app key, environment `production` for live and `stage` for the workspace);
6
+ * foundry only sets up the backend. One on/off module, no fields. A server app declares `uses.reporting` itself
7
+ * and gets its key from foundry.
8
+ */
9
+ export const reporting = {
10
+ view: {
11
+ id: 'reporting',
12
+ name: 'Report errors',
13
+ line: 'We keep the errors the app runs into, so you can see what went wrong and when.',
14
+ category: 'Data',
15
+ icon: 'alert',
16
+ runtimes: ['worker'],
17
+ requires: [],
18
+ availability: 'ready',
19
+ fields: [],
20
+ adds: [
21
+ {
22
+ name: 'Errors',
23
+ hint: 'What went wrong in the app, how often and since when, on its Errors page. Kept in your own company only.',
24
+ },
25
+ ],
26
+ },
27
+ toEntry: (entry, values) => setUse(entry, 'reporting', values === null ? undefined : true),
28
+ fromEntry: (entry) => (entry.uses?.reporting === true ? {} : null),
29
+ };
@@ -50,6 +50,14 @@ export const signIn = {
50
50
  },
51
51
  ],
52
52
  },
53
+ {
54
+ key: 'team',
55
+ label: 'Your team signs in too',
56
+ help: 'The people in your company sign in to the same app with their own company account.',
57
+ kind: 'switch',
58
+ showWhen: forCustomers,
59
+ runtimes: ['server'],
60
+ },
53
61
  {
54
62
  key: 'registration',
55
63
  label: 'Who can make an account',
@@ -142,6 +150,9 @@ export const signIn = {
142
150
  },
143
151
  check(values) {
144
152
  const customers = values.users === 'customers';
153
+ if (values.team === true && !customers) {
154
+ return problem('team', 'Your team already signs in when "Who signs in" is "Your team". Turn this on for an app your customers sign in to.');
155
+ }
145
156
  if (!customers && values.registration === 'open') {
146
157
  return problem('registration', 'Only customers can make their own account. For your team, choose "Only people you invite".');
147
158
  }
@@ -195,9 +206,13 @@ export const signIn = {
195
206
  }
196
207
  // The parts other modules own (mcp, workspaces) and the operator's replacesBrokerEntry stay.
197
208
  const kept = omit(entry.uses?.auth ?? {}, 'signIn', 'signIns', 'secondFactor', 'room');
209
+ // ADR 0003: customers with the team beside them is both audiences; one audience stays the single value.
210
+ const users = values.users === 'customers' && values.team === true
211
+ ? ['customers', 'staff']
212
+ : values.users;
198
213
  const auth = {
199
214
  ...kept,
200
- users: values.users,
215
+ users,
201
216
  registration: values.registration,
202
217
  };
203
218
  const methods = methodsOf(values);
@@ -219,8 +234,11 @@ export const signIn = {
219
234
  const auth = entry.uses?.auth;
220
235
  if (!auth)
221
236
  return null;
237
+ const listed = Array.isArray(auth.users) ? auth.users : [auth.users];
238
+ const audiences = ['customers', 'staff'].filter((a) => listed.includes(a));
222
239
  const values = {
223
- users: auth.users,
240
+ users: audiences.includes('customers') ? 'customers' : 'staff',
241
+ ...(audiences.length === 2 ? { team: true } : {}),
224
242
  registration: auth.registration,
225
243
  methods: auth.signIns ?? [auth.signIn ?? 'password'],
226
244
  secondFactor: auth.secondFactor ?? 'off',
@@ -34,6 +34,8 @@ export interface ModuleField {
34
34
  pattern?: string;
35
35
  /** The longest text, the most list items, or the largest number. */
36
36
  max?: number;
37
+ /** For `number`: the smallest. Every `number` field is a whole number. */
38
+ min?: number;
37
39
  /** Shown only while another field of the same module has this value. */
38
40
  showWhen?: {
39
41
  key: string;
@@ -40,6 +40,9 @@ export const workspaces = {
40
40
  if (modules['sign-in']?.users !== 'customers') {
41
41
  return problem(null, 'Workspaces are for your customers. In Sign-in, choose "Your customers" first.');
42
42
  }
43
+ if (modules['sign-in']?.team === true) {
44
+ return problem(null, 'Workspaces are not ready yet for an app your team signs in to as well. In Sign-in, turn off "Your team signs in too".');
45
+ }
43
46
  const roles = list(values, 'roles') ?? [];
44
47
  const seen = new Set();
45
48
  for (const [index, role] of roles.entries()) {