@dokebi/cli 0.4.2 → 0.4.27

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 (200) hide show
  1. package/AI.md +279 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2520 -258
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +638 -37
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +15 -0
  46. package/dist/check/shared-state-rules.js +117 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +381 -36
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-secrets.d.ts +46 -0
  70. package/dist/config-secrets.js +187 -0
  71. package/dist/config-show.d.ts +82 -0
  72. package/dist/config-show.js +563 -0
  73. package/dist/config-values.d.ts +8 -0
  74. package/dist/config-values.js +68 -0
  75. package/dist/configuration-scaffold.d.ts +42 -0
  76. package/dist/configuration-scaffold.js +258 -4
  77. package/dist/configuration.d.ts +63 -3
  78. package/dist/configuration.js +178 -4
  79. package/dist/deploy-scripts.d.ts +4 -0
  80. package/dist/deploy-scripts.js +361 -0
  81. package/dist/deploy-templates.d.ts +46 -0
  82. package/dist/deploy-templates.js +379 -0
  83. package/dist/deploy.d.ts +46 -0
  84. package/dist/deploy.js +592 -0
  85. package/dist/deployment.d.ts +2 -0
  86. package/dist/deployment.js +111 -0
  87. package/dist/describe-db.d.ts +22 -0
  88. package/dist/describe-db.js +176 -0
  89. package/dist/describe.d.ts +92 -0
  90. package/dist/describe.js +417 -0
  91. package/dist/dev-compose.d.ts +20 -0
  92. package/dist/dev-compose.js +154 -0
  93. package/dist/dev-guard.js +44 -0
  94. package/dist/dev-host.d.ts +13 -0
  95. package/dist/dev-host.js +55 -0
  96. package/dist/dev-infra.d.ts +72 -4
  97. package/dist/dev-infra.js +237 -17
  98. package/dist/dev-watch.d.ts +34 -0
  99. package/dist/dev-watch.js +192 -0
  100. package/dist/diagnostics.d.ts +10 -7
  101. package/dist/diagnostics.js +22 -4
  102. package/dist/drift.d.ts +50 -0
  103. package/dist/drift.js +218 -0
  104. package/dist/env-file.d.ts +25 -1
  105. package/dist/env-file.js +64 -6
  106. package/dist/errors.d.ts +7 -1
  107. package/dist/example-config.d.ts +2 -0
  108. package/dist/example-config.js +3 -0
  109. package/dist/first-admin.d.ts +43 -0
  110. package/dist/first-admin.js +175 -0
  111. package/dist/formatter-ignore.d.ts +37 -0
  112. package/dist/formatter-ignore.js +344 -0
  113. package/dist/generator.d.ts +45 -3
  114. package/dist/generator.js +205 -15
  115. package/dist/guide.d.ts +1 -1
  116. package/dist/guide.js +42 -21
  117. package/dist/install-module.d.ts +20 -2
  118. package/dist/install-module.js +45 -8
  119. package/dist/install-plan.js +5 -3
  120. package/dist/layout.d.ts +12 -1
  121. package/dist/layout.js +19 -6
  122. package/dist/log-writer-control.d.ts +2 -0
  123. package/dist/log-writer-control.js +17 -2
  124. package/dist/log-writer.js +43 -10
  125. package/dist/logging-config.d.ts +8 -3
  126. package/dist/logging-config.js +19 -5
  127. package/dist/manifest.d.ts +29 -1
  128. package/dist/manifest.js +84 -6
  129. package/dist/merge-install-defaults.d.ts +0 -1
  130. package/dist/merge-install-defaults.js +13 -47
  131. package/dist/npm-client.d.ts +40 -4
  132. package/dist/npm-client.js +152 -15
  133. package/dist/npm-paths.js +18 -4
  134. package/dist/openapi.d.ts +27 -0
  135. package/dist/openapi.js +649 -0
  136. package/dist/option-fixes.d.ts +39 -0
  137. package/dist/option-fixes.js +130 -0
  138. package/dist/output.d.ts +1 -1
  139. package/dist/output.js +5 -5
  140. package/dist/plugin-lifecycle.d.ts +34 -2
  141. package/dist/plugin-lifecycle.js +195 -16
  142. package/dist/plugin-options.d.ts +19 -4
  143. package/dist/plugin-options.js +138 -6
  144. package/dist/plugins-command.js +44 -7
  145. package/dist/process-group.d.ts +39 -0
  146. package/dist/process-group.js +132 -0
  147. package/dist/production-doctor.d.ts +3 -0
  148. package/dist/production-doctor.js +176 -0
  149. package/dist/project-guide.d.ts +19 -1
  150. package/dist/project-guide.js +119 -20
  151. package/dist/project-inspector.d.ts +15 -0
  152. package/dist/project-inspector.js +497 -33
  153. package/dist/project-state.d.ts +5 -0
  154. package/dist/project-state.js +8 -0
  155. package/dist/project.d.ts +203 -19
  156. package/dist/project.js +3006 -333
  157. package/dist/recipe-facts.d.ts +127 -0
  158. package/dist/recipe-facts.js +563 -0
  159. package/dist/recipe-login.d.ts +63 -0
  160. package/dist/recipe-login.js +464 -0
  161. package/dist/recipe-mail.d.ts +21 -0
  162. package/dist/recipe-mail.js +214 -0
  163. package/dist/recipe-members.d.ts +35 -0
  164. package/dist/recipe-members.js +178 -0
  165. package/dist/recipe-record.d.ts +82 -7
  166. package/dist/recipe-record.js +226 -22
  167. package/dist/recipe-reset.d.ts +2 -0
  168. package/dist/recipe-reset.js +117 -0
  169. package/dist/recipe-site.d.ts +48 -0
  170. package/dist/recipe-site.js +396 -0
  171. package/dist/recipe-step.d.ts +42 -0
  172. package/dist/recipe-step.js +89 -0
  173. package/dist/recipe.d.ts +56 -20
  174. package/dist/recipe.js +703 -131
  175. package/dist/registry.d.ts +98 -2
  176. package/dist/registry.js +171 -5
  177. package/dist/reloadable.d.ts +35 -0
  178. package/dist/reloadable.js +257 -0
  179. package/dist/remote-verify.d.ts +31 -0
  180. package/dist/remote-verify.js +204 -0
  181. package/dist/server.d.ts +43 -4
  182. package/dist/server.js +601 -52
  183. package/dist/settings.d.ts +48 -0
  184. package/dist/settings.js +168 -0
  185. package/dist/site-auth.d.ts +39 -0
  186. package/dist/site-auth.js +195 -0
  187. package/dist/template-fixes.d.ts +26 -0
  188. package/dist/template-fixes.js +285 -0
  189. package/dist/test-reporter.d.ts +49 -0
  190. package/dist/test-reporter.js +190 -0
  191. package/dist/transaction.d.ts +47 -0
  192. package/dist/transaction.js +241 -7
  193. package/dist/verify-tests.d.ts +98 -0
  194. package/dist/verify-tests.js +449 -0
  195. package/dist/verify.d.ts +103 -3
  196. package/dist/verify.js +272 -21
  197. package/dist/view-choice.d.ts +58 -0
  198. package/dist/view-choice.js +131 -0
  199. package/dist/vite-watch.js +4 -2
  200. package/package.json +8 -6
@@ -0,0 +1,450 @@
1
+ // Security rules of `dokebi check` over the TypeScript AST (D-0076 §6), on only when the plugin
2
+ // they protect is installed (an app without @dokebi/auth may compare whatever it likes):
3
+ // plain-password-compare (error, @dokebi/auth) a value named like a password (a word of its
4
+ // name is password, passwd, passphrase, pass, pw or pwd) compared with
5
+ // ===, !==, == or != (not with '', null, undefined, a boolean or a
6
+ // number; not two values that both come from the request input, such
7
+ // as a new password and its confirmation field (AI-M05 F4); not two
8
+ // of them where one is a confirmation such as passwordConfirm, unless
9
+ // one side is a stored value: a hash, or a field of a user, row,
10
+ // account or credentials object), or given to node:crypto's timingSafeEqual, scrypt,
11
+ // pbkdf2 or argon2, to the update() of a createHash()/createHmac(), or
12
+ // to bcrypt/argon2 packages: the plugin checks and hashes passwords.
13
+ // csrf-exempt-unverified (warning, @dokebi/csrf) a route whose `config` has `csrf: false` and
14
+ // whose registration call (with the handler functions of the same file
15
+ // it names) shows no rawBody, signature check (signature, createHmac,
16
+ // timingSafeEqual, .verify(…)), no seal, no @dokebi/auth token refresh
17
+ // (tokens.refresh(…): the refresh token is the proof), and no `oauth`
18
+ // key or `auth: { via: 'token' }` in the same config (a Bearer-only
19
+ // route: no cookie authenticates it; `via: 'any'` or 'session' does
20
+ // not count): an exempt route must prove the sender some other way.
21
+ // Both are heuristics on names and text; a `// dokebi-check: allow <rule> <reason>` comment
22
+ // silences a deliberate case.
23
+ import { field, nodes, property, } from './typescript.js';
24
+ const PASSWORD_WORDS = new Set(['password', 'passwd', 'passphrase', 'pass', 'pw', 'pwd']);
25
+ /** A second password typed to confirm the first: comparing the two is fine. */
26
+ const CONFIRM_WORDS = new Set([
27
+ 'confirm',
28
+ 'confirmation',
29
+ 'confirmed',
30
+ 'repeat',
31
+ 'again',
32
+ 'retype',
33
+ ]);
34
+ /**
35
+ * Objects that hold request input: a value read from them (`body.password`, `request.query.x`,
36
+ * a local destructured from `request.body`) is something the user just typed.
37
+ */
38
+ const INPUT_HOLDERS = new Set([
39
+ 'body',
40
+ 'query',
41
+ 'params',
42
+ 'input',
43
+ 'inputs',
44
+ 'form',
45
+ 'fields',
46
+ 'values',
47
+ 'payload',
48
+ 'submitted',
49
+ ]);
50
+ /** Objects that hold stored records: a password read from them is the stored one. */
51
+ const STORED_HOLDERS = new Set([
52
+ 'db',
53
+ 'database',
54
+ 'row',
55
+ 'rows',
56
+ 'record',
57
+ 'records',
58
+ 'user',
59
+ 'users',
60
+ 'account',
61
+ 'accounts',
62
+ 'admin',
63
+ 'admins',
64
+ 'author',
65
+ 'authors',
66
+ 'member',
67
+ 'members',
68
+ 'credential',
69
+ 'credentials',
70
+ 'existing',
71
+ 'model',
72
+ 'entity',
73
+ 'stored',
74
+ 'saved',
75
+ ]);
76
+ /** Words of a value's own name that say it is stored: `passwordHash`, `pass_phc`. */
77
+ const STORED_VALUES = new Set(['hash', 'hashed', 'digest', 'phc', 'stored', 'saved']);
78
+ /** Calls that only reshape a typed value (`String(body.next).trim()`). */
79
+ const RESHAPE = new Set([
80
+ 'String',
81
+ 'trim',
82
+ 'trimStart',
83
+ 'trimEnd',
84
+ 'normalize',
85
+ 'toString',
86
+ 'toLowerCase',
87
+ 'toUpperCase',
88
+ ]);
89
+ const COMPARE = [
90
+ 'EqualsEqualsEqualsToken',
91
+ 'ExclamationEqualsEqualsToken',
92
+ 'EqualsEqualsToken',
93
+ 'ExclamationEqualsToken',
94
+ ];
95
+ /** node:crypto functions that hash or compare secrets. */
96
+ const CRYPTO = new Set([
97
+ 'timingSafeEqual',
98
+ 'scrypt',
99
+ 'scryptSync',
100
+ 'pbkdf2',
101
+ 'pbkdf2Sync',
102
+ 'argon2',
103
+ 'argon2Sync',
104
+ ]);
105
+ /** Password packages an app might call instead of the plugin. */
106
+ const PASSWORD_PACKAGES = new Set(['bcrypt', 'bcryptjs', 'argon2']);
107
+ /**
108
+ * Proofs of the sender in a route's text: a raw-body signature, a seal, or @dokebi/auth's token
109
+ * refresh (`request.auth.tokens.refresh(token)`, `realm.tokens.refresh(…)`): the refresh token
110
+ * the request carries is the credential, which another site does not have.
111
+ */
112
+ const VERIFIED = /rawBody|signature|createHmac|timingSafeEqual|\.verify\s*\(|\bseal|\btokens\s*\.\s*refresh\s*\(/i;
113
+ /** `passwordHash` → [password, hash]; `pass_phc` → [pass, phc]; `PW` → [pw]. */
114
+ export function nameWords(name) {
115
+ return name
116
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
117
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
118
+ .split(/[\s_$-]+/)
119
+ .filter(Boolean)
120
+ .map((word) => word.toLowerCase());
121
+ }
122
+ export function passwordLike(name) {
123
+ return nameWords(name).some((word) => PASSWORD_WORDS.has(word));
124
+ }
125
+ export function securityRules(file, kinds, installed, report) {
126
+ const text = file.text;
127
+ const compare = new Set(COMPARE.map((name) => kinds[name]));
128
+ const snippet = (node) => {
129
+ const source = text.slice(node.getStart(), node.end).replace(/\s+/g, ' ');
130
+ return source.length > 60 ? `${source.slice(0, 59)}…` : source;
131
+ };
132
+ const unwrap = (node) => {
133
+ let current = node;
134
+ while (current &&
135
+ (current.kind === kinds.ParenthesizedExpression ||
136
+ current.kind === kinds.AsExpression ||
137
+ current.kind === kinds.NonNullExpression))
138
+ current = field(current, 'expression');
139
+ return current;
140
+ };
141
+ /** The name an expression reads: `password`, `body.password`, `body['password']`. */
142
+ const nameOf = (node) => {
143
+ const current = unwrap(node);
144
+ if (!current)
145
+ return undefined;
146
+ if (current.kind === kinds.Identifier)
147
+ return String(property(current, 'text'));
148
+ if (current.kind === kinds.PropertyAccessExpression) {
149
+ const name = field(current, 'name');
150
+ return name ? String(property(name, 'text')) : undefined;
151
+ }
152
+ if (current.kind === kinds.ElementAccessExpression) {
153
+ const key = field(current, 'argumentExpression');
154
+ return key &&
155
+ (key.kind === kinds.StringLiteral || key.kind === kinds.NoSubstitutionTemplateLiteral)
156
+ ? String(property(key, 'text'))
157
+ : undefined;
158
+ }
159
+ return undefined;
160
+ };
161
+ /** '', null, undefined, true, false or a number: a presence or shape test, not a check. */
162
+ const harmless = (node) => {
163
+ const current = unwrap(node);
164
+ if (!current)
165
+ return true;
166
+ if (current.kind === kinds.StringLiteral ||
167
+ current.kind === kinds.NoSubstitutionTemplateLiteral)
168
+ return String(property(current, 'text')) === '';
169
+ if (current.kind === kinds.Identifier)
170
+ return String(property(current, 'text')) === 'undefined';
171
+ return (current.kind === kinds.NullKeyword ||
172
+ current.kind === kinds.TrueKeyword ||
173
+ current.kind === kinds.FalseKeyword ||
174
+ current.kind === kinds.NumericLiteral ||
175
+ current.kind === kinds.TypeOfExpression);
176
+ };
177
+ /** Whether a password-like name is read anywhere inside `node`. */
178
+ const readsPassword = (node) => {
179
+ let found = false;
180
+ const walk = (current) => {
181
+ if (found)
182
+ return undefined;
183
+ const name = nameOf(current);
184
+ if ((current.kind === kinds.Identifier ||
185
+ current.kind === kinds.PropertyAccessExpression ||
186
+ current.kind === kinds.ElementAccessExpression) &&
187
+ name !== undefined &&
188
+ passwordLike(name)) {
189
+ found = true;
190
+ return undefined;
191
+ }
192
+ current.forEachChild(walk);
193
+ return undefined;
194
+ };
195
+ walk(node);
196
+ return found;
197
+ };
198
+ const calleeName = (call) => {
199
+ const callee = unwrap(field(call, 'expression'));
200
+ if (callee?.kind === kinds.Identifier)
201
+ return String(property(callee, 'text'));
202
+ if (callee?.kind === kinds.PropertyAccessExpression) {
203
+ const name = field(callee, 'name');
204
+ return name ? String(property(name, 'text')) : undefined;
205
+ }
206
+ return undefined;
207
+ };
208
+ const receiverOf = (call) => {
209
+ const callee = unwrap(field(call, 'expression'));
210
+ return callee?.kind === kinds.PropertyAccessExpression
211
+ ? unwrap(field(callee, 'expression'))
212
+ : undefined;
213
+ };
214
+ /** `createHash('sha256')` somewhere at the start of `node`'s call chain. */
215
+ const fromHash = (node) => {
216
+ let current = unwrap(node);
217
+ while (current) {
218
+ if (current.kind === kinds.CallExpression) {
219
+ const name = calleeName(current);
220
+ if (name === 'createHash' || name === 'createHmac')
221
+ return true;
222
+ current = receiverOf(current);
223
+ }
224
+ else if (current.kind === kinds.PropertyAccessExpression)
225
+ current = unwrap(field(current, 'expression'));
226
+ else
227
+ return false;
228
+ }
229
+ return false;
230
+ };
231
+ /**
232
+ * Where the values of local names come from in this file: initializers of `const x = …` and,
233
+ * for `const { a, b: c } = source`, the source object. A name declared several times keeps
234
+ * every source (a stored one anywhere makes it stored).
235
+ */
236
+ const sources = new Map();
237
+ const collect = (node) => {
238
+ if (node.kind === kinds.VariableDeclaration) {
239
+ const name = field(node, 'name');
240
+ const initializer = field(node, 'initializer');
241
+ if (name && initializer) {
242
+ if (name.kind === kinds.Identifier) {
243
+ const key = String(property(name, 'text'));
244
+ sources.set(key, [...(sources.get(key) ?? []), initializer]);
245
+ }
246
+ else if (name.kind === kinds.ObjectBindingPattern)
247
+ for (const element of nodes(name, 'elements')) {
248
+ const local = field(element, 'name');
249
+ if (element.kind === kinds.BindingElement && local?.kind === kinds.Identifier) {
250
+ const key = String(property(local, 'text'));
251
+ sources.set(key, [...(sources.get(key) ?? []), initializer]);
252
+ }
253
+ }
254
+ }
255
+ }
256
+ node.forEachChild(collect);
257
+ return undefined;
258
+ };
259
+ file.forEachChild(collect);
260
+ const words = (name, set) => nameWords(name).some((word) => set.has(word));
261
+ /**
262
+ * Where a compared value comes from: `input` (request input: a field of body, query, params,
263
+ * a form, or a local made from one), `stored` (a hash, a field of a user, row, account or
264
+ * credentials object, a local made from one, or a call that loads one) or `unknown`.
265
+ */
266
+ const origin = (node, depth = 0) => {
267
+ let current = unwrap(node);
268
+ while (current?.kind === kinds.AwaitExpression)
269
+ current = unwrap(field(current, 'expression'));
270
+ if (!current || depth > 8)
271
+ return 'unknown';
272
+ if (current.kind === kinds.Identifier) {
273
+ const name = String(property(current, 'text'));
274
+ if (words(name, STORED_VALUES) || words(name, STORED_HOLDERS))
275
+ return 'stored';
276
+ // Where it was declared counts first: a `body` loaded from the database is not input.
277
+ const from = (sources.get(name) ?? []).map((item) => origin(item, depth + 1));
278
+ if (from.includes('stored'))
279
+ return 'stored';
280
+ if (INPUT_HOLDERS.has(name))
281
+ return 'input';
282
+ return from.length && from.every((item) => item === 'input') ? 'input' : 'unknown';
283
+ }
284
+ if (current.kind === kinds.PropertyAccessExpression ||
285
+ current.kind === kinds.ElementAccessExpression) {
286
+ const own = nameOf(current);
287
+ if (own !== undefined && words(own, STORED_VALUES))
288
+ return 'stored';
289
+ // The holder itself (`request.body`, `request.user`) as the value: a destructuring source.
290
+ if (own !== undefined && INPUT_HOLDERS.has(own))
291
+ return 'input';
292
+ if (own !== undefined && STORED_HOLDERS.has(own))
293
+ return 'stored';
294
+ const holder = unwrap(field(current, 'expression'));
295
+ const holderName = nameOf(holder);
296
+ if (holderName !== undefined && words(holderName, STORED_HOLDERS))
297
+ return 'stored';
298
+ const inner = origin(holder, depth + 1);
299
+ if (inner === 'stored')
300
+ return 'stored';
301
+ return inner === 'input' || (holderName !== undefined && INPUT_HOLDERS.has(holderName))
302
+ ? 'input'
303
+ : 'unknown';
304
+ }
305
+ if (current.kind === kinds.CallExpression) {
306
+ const name = calleeName(current);
307
+ if (name !== undefined && (words(name, STORED_HOLDERS) || words(name, STORED_VALUES)))
308
+ return 'stored';
309
+ if (name === undefined || !RESHAPE.has(name))
310
+ return 'unknown';
311
+ const receiver = receiverOf(current);
312
+ const args = nodes(current, 'arguments');
313
+ return origin(receiver ?? args[0], depth + 1);
314
+ }
315
+ if (current.kind === kinds.BinaryExpression) {
316
+ const operator = field(current, 'operatorToken')?.kind;
317
+ // `body.password ?? ''`: the typed value, or an empty default.
318
+ if (operator === kinds.QuestionQuestionToken || operator === kinds.BarBarToken)
319
+ return origin(field(current, 'left'), depth + 1);
320
+ return 'unknown';
321
+ }
322
+ if (current.kind === kinds.ConditionalExpression) {
323
+ const branches = [field(current, 'whenTrue'), field(current, 'whenFalse')].map((item) => harmless(item) ? 'input' : origin(item, depth + 1));
324
+ if (branches.includes('stored'))
325
+ return 'stored';
326
+ return branches.every((item) => item === 'input') ? 'input' : 'unknown';
327
+ }
328
+ return 'unknown';
329
+ };
330
+ const passwordFix = 'let @dokebi/auth do it: sign in with await request.auth.attempt({ identity, password }) (it checks, throttles and starts the session); hash new passwords with passwords.hash(password) and check one with passwords.verify(hash, password) (the plugin API or @dokebi/auth); never compare or hash a password yourself';
331
+ /** Function bodies of this file by name (a route's handler given by name). */
332
+ const declarations = new Map();
333
+ for (const statement of nodes(file, 'statements')) {
334
+ if (statement.kind === kinds.FunctionDeclaration) {
335
+ const name = field(statement, 'name');
336
+ if (name)
337
+ declarations.set(String(property(name, 'text')), statement);
338
+ }
339
+ else if (statement.kind === kinds.VariableStatement) {
340
+ const list = field(statement, 'declarationList');
341
+ for (const declaration of list ? nodes(list, 'declarations') : []) {
342
+ const name = field(declaration, 'name');
343
+ if (name?.kind === kinds.Identifier)
344
+ declarations.set(String(property(name, 'text')), declaration);
345
+ }
346
+ }
347
+ }
348
+ /** The registration call text plus the handlers of this file it names. */
349
+ const routeText = (call) => {
350
+ const parts = [text.slice(call.getStart(), call.end)];
351
+ const named = (node) => {
352
+ const current = unwrap(node);
353
+ if (current?.kind !== kinds.Identifier)
354
+ return;
355
+ const declaration = declarations.get(String(property(current, 'text')));
356
+ if (declaration)
357
+ parts.push(text.slice(declaration.getStart(), declaration.end));
358
+ };
359
+ for (const argument of nodes(call, 'arguments')) {
360
+ named(argument);
361
+ if (unwrap(argument)?.kind === kinds.ObjectLiteralExpression)
362
+ for (const item of nodes(unwrap(argument), 'properties'))
363
+ if (item.kind === kinds.PropertyAssignment)
364
+ named(field(item, 'initializer'));
365
+ }
366
+ return parts.join('\n');
367
+ };
368
+ const propertyName = (node) => {
369
+ const name = field(node, 'name');
370
+ if (!name)
371
+ return undefined;
372
+ if (name.kind === kinds.Identifier || name.kind === kinds.StringLiteral)
373
+ return String(property(name, 'text'));
374
+ return undefined;
375
+ };
376
+ const visit = (node) => {
377
+ if (installed.auth && node.kind === kinds.BinaryExpression) {
378
+ const operator = field(node, 'operatorToken');
379
+ if (operator && compare.has(operator.kind)) {
380
+ const left = field(node, 'left');
381
+ const right = field(node, 'right');
382
+ const leftName = nameOf(left);
383
+ const rightName = nameOf(right);
384
+ const leftPassword = leftName !== undefined && passwordLike(leftName);
385
+ const rightPassword = rightName !== undefined && passwordLike(rightName);
386
+ const confirmation = [leftName, rightName].some((name) => name !== undefined && nameWords(name).some((word) => CONFIRM_WORDS.has(word)));
387
+ if ((leftPassword && !harmless(right)) || (rightPassword && !harmless(left))) {
388
+ // AI-M05 F4: two values the user typed (a new password and its confirmation) may be
389
+ // compared; a typed value against a stored one (a hash, a user or row field) may not.
390
+ const sides = [origin(left), origin(right)];
391
+ const typed = sides.every((side) => side === 'input');
392
+ const stored = sides.includes('stored');
393
+ if (!(typed || (leftPassword && rightPassword && confirmation && !stored)))
394
+ report(node, 'plain-password-compare', `${snippet(node)} compares a password itself: a plain comparison leaks timing, and stored passwords must be hashes the plugin checks`, passwordFix);
395
+ }
396
+ }
397
+ }
398
+ else if (installed.auth && node.kind === kinds.CallExpression) {
399
+ const name = calleeName(node);
400
+ const receiver = receiverOf(node);
401
+ const receiverName = receiver?.kind === kinds.Identifier ? String(property(receiver, 'text')) : undefined;
402
+ const hashing = (name !== undefined && CRYPTO.has(name)) ||
403
+ (name === 'update' && fromHash(receiver)) ||
404
+ (receiverName !== undefined && PASSWORD_PACKAGES.has(receiverName));
405
+ if (hashing && nodes(node, 'arguments').some(readsPassword))
406
+ report(node, 'plain-password-compare', `${snippet(node)} hashes or compares a password itself (@dokebi/auth is installed)`, passwordFix);
407
+ }
408
+ else if (installed.csrf && node.kind === kinds.PropertyAssignment) {
409
+ const value = unwrap(field(node, 'initializer'));
410
+ const object = node.parent;
411
+ const config = object?.parent;
412
+ if (propertyName(node) === 'csrf' &&
413
+ value?.kind === kinds.FalseKeyword &&
414
+ object?.kind === kinds.ObjectLiteralExpression &&
415
+ config?.kind === kinds.PropertyAssignment &&
416
+ propertyName(config) === 'config') {
417
+ let call = config.parent;
418
+ while (call && call.kind !== kinds.CallExpression)
419
+ call = call.parent;
420
+ // A route that requires an OAuth Bearer token (config.oauth) is not authenticated by
421
+ // the visitor's cookies, so another site cannot post to it as the visitor.
422
+ // So is a route of @dokebi/auth that reads Bearer tokens only (auth: { via: 'token' }):
423
+ // no session cookie authenticates it. `via: 'any'` and 'session' accept the cookie, so
424
+ // they prove nothing.
425
+ const properties = nodes(object, 'properties');
426
+ const tokenOnly = properties.some((p) => {
427
+ if (p.kind !== kinds.PropertyAssignment || propertyName(p) !== 'auth')
428
+ return false;
429
+ const auth = unwrap(field(p, 'initializer'));
430
+ if (auth?.kind !== kinds.ObjectLiteralExpression)
431
+ return false;
432
+ return nodes(auth, 'properties').some((item) => {
433
+ if (item.kind !== kinds.PropertyAssignment || propertyName(item) !== 'via')
434
+ return false;
435
+ const via = unwrap(field(item, 'initializer'));
436
+ return ((via?.kind === kinds.StringLiteral ||
437
+ via?.kind === kinds.NoSubstitutionTemplateLiteral) &&
438
+ property(via, 'text') === 'token');
439
+ });
440
+ });
441
+ const bearer = tokenOnly || properties.some((p) => propertyName(p) === 'oauth');
442
+ if (!bearer && (!call || !VERIFIED.test(routeText(call))))
443
+ report(node, 'csrf-exempt-unverified', "A route with config: { csrf: false } shows no other proof of its sender (no rawBody, signature check or seal in its registration or its handler in this file): any site can post to it with the visitor's cookies", 'remove csrf: false (forms and scripts send the token: data.csrf.field, the X-XSRF-TOKEN header; Inertia does it by itself), or verify the sender in the handler (an HMAC signature over the raw body with timingSafeEqual, a sealed request); never turn the check off to make a test pass. A deliberate case takes // dokebi-check: allow csrf-exempt-unverified <reason>');
444
+ }
445
+ }
446
+ node.forEachChild(visit);
447
+ return undefined;
448
+ };
449
+ file.forEachChild(visit);
450
+ }
@@ -0,0 +1,15 @@
1
+ import type { Problem } from './problems.js';
2
+ type Options = Record<string, unknown>;
3
+ /** One piece of state kept in process memory: its option path and what it would lose. */
4
+ export interface Kept {
5
+ readonly path: readonly string[];
6
+ readonly what: string;
7
+ readonly fix: (redis: string | undefined) => string;
8
+ }
9
+ /**
10
+ * The state of each installed plugin that stays in one process with these options (also
11
+ * `doctor --production`, D-0184: the plugins refuse it in production too).
12
+ */
13
+ export declare function kept(installed: ReadonlySet<string>, options: Options): Kept[];
14
+ export declare function sharedStateProblems(root: string, installed: ReadonlySet<string>): Promise<Problem[]>;
15
+ export {};
@@ -0,0 +1,117 @@
1
+ // The shared-state rule of `dokebi check` (D-0161, an error):
2
+ // shared-state-memory an app with more than one worker (options.application-runtime.workers
3
+ // above 1) keeps state its workers must share in process memory: the
4
+ // session store, auth tokens and login counts, OAuth grants, rate-limit
5
+ // counters, seal replay nonces or a local WebSocket hub. Each worker would
6
+ // hold its own (R1: 4 of 4 reset links answered "invalid"), and the plugins
7
+ // refuse to start that way in a cluster worker and in production.
8
+ // Production with one worker is refused at start too; dokebi.yaml cannot tell a production run.
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { CONFIG_FILE, parseProjectConfig } from '../configuration.js';
12
+ import { debug, describeError, isMissing } from '../diagnostics.js';
13
+ import { CliError } from '../errors.js';
14
+ import { optionLine } from './project-rules.js';
15
+ const RULE = 'shared-state-memory';
16
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
17
+ const redisValue = (redis) => `driver: redis, connection: ${redis ?? '<a dokebi-redis connection>'}`;
18
+ const addRedis = (redis) => redis === undefined ? ' (npx dokebi add @dokebi/redis first)' : '';
19
+ /**
20
+ * The state of each installed plugin that stays in one process with these options (also
21
+ * `doctor --production`, D-0184: the plugins refuse it in production too).
22
+ */
23
+ export function kept(installed, options) {
24
+ const found = [];
25
+ const plugin = (id) => record(options[id]);
26
+ if (installed.has('dokebi-session') &&
27
+ (record(plugin('dokebi-session').store).driver ?? 'memory') === 'memory')
28
+ found.push({
29
+ path: ['dokebi-session', 'store'],
30
+ what: 'sessions',
31
+ fix: (redis) => `set options.dokebi-session.store: { ${redisValue(redis)} }${addRedis(redis)}`,
32
+ });
33
+ if (installed.has('dokebi-auth')) {
34
+ const auth = plugin('dokebi-auth');
35
+ if ((record(auth.tokens).driver ?? 'memory') === 'memory')
36
+ found.push({
37
+ path: ['dokebi-auth', 'tokens'],
38
+ what: 'auth tokens (mail links, API and remember-me tokens)',
39
+ fix: (redis) => `set options.dokebi-auth.tokens: { ${redisValue(redis)} } (or { driver: sql, connection: <a dokebi-db connection> })${addRedis(redis)}`,
40
+ });
41
+ if ((record(record(auth.throttle).store).driver ?? 'memory') === 'memory')
42
+ found.push({
43
+ path: ['dokebi-auth', 'throttle'],
44
+ what: 'login attempt counts',
45
+ fix: (redis) => `set options.dokebi-auth.throttle.store: { ${redisValue(redis)} }${addRedis(redis)}`,
46
+ });
47
+ }
48
+ if (installed.has('dokebi-oauth') &&
49
+ (record(plugin('dokebi-oauth').store).driver ?? 'memory') === 'memory')
50
+ found.push({
51
+ path: ['dokebi-oauth', 'store'],
52
+ what: 'OAuth grants and tokens',
53
+ fix: (redis) => `set options.dokebi-oauth.store: { ${redisValue(redis)} } (or { driver: sql, connection: <a dokebi-db connection> })${addRedis(redis)}`,
54
+ });
55
+ if (installed.has('dokebi-rate-limit') &&
56
+ (plugin('dokebi-rate-limit').driver ?? 'local') === 'local')
57
+ found.push({
58
+ path: ['dokebi-rate-limit'],
59
+ what: 'rate-limit counters',
60
+ fix: (redis) => `set options.dokebi-rate-limit: { ${redisValue(redis)} }${addRedis(redis)}`,
61
+ });
62
+ const hub = plugin('dokebi-websocket-hub');
63
+ const hubShared = installed.has('dokebi-websocket-hub') && hub.mode === 'cluster';
64
+ if (installed.has('dokebi-websocket-hub') && !hubShared && hub.allowInCluster !== true)
65
+ found.push({
66
+ path: ['dokebi-websocket-hub'],
67
+ what: 'WebSocket presence (a local hub)',
68
+ fix: (redis) => `set options.dokebi-websocket-hub: { mode: cluster, redisConnection: ${redis ?? '<a dokebi-redis connection>'} }${addRedis(redis)}`,
69
+ });
70
+ if (installed.has('dokebi-seal')) {
71
+ const nonces = record(plugin('dokebi-seal').nonces);
72
+ const driver = nonces.driver ?? 'auto';
73
+ const shared = driver === 'redis' || ((driver === 'auto' || driver === 'hub') && hubShared);
74
+ if (!shared && nonces.allowInCluster !== true)
75
+ found.push({
76
+ path: ['dokebi-seal', 'nonces'],
77
+ what: 'replay nonces of sealed requests',
78
+ fix: (redis) => `set options.dokebi-seal.nonces: { ${redisValue(redis)} }${addRedis(redis)}`,
79
+ });
80
+ }
81
+ return found;
82
+ }
83
+ export async function sharedStateProblems(root, installed) {
84
+ let source;
85
+ try {
86
+ source = await readFile(join(root, CONFIG_FILE), 'utf8');
87
+ }
88
+ catch (error) {
89
+ if (isMissing(error))
90
+ return [];
91
+ throw new CliError('READ_FAILED', `Cannot read ${CONFIG_FILE}`, { cause: error });
92
+ }
93
+ let options;
94
+ try {
95
+ options = record(parseProjectConfig(source).options);
96
+ }
97
+ catch (error) {
98
+ // doctor reports an invalid dokebi.yaml; this rule has nothing to read then.
99
+ debug('check: shared-state rule skipped, dokebi.yaml not readable', {
100
+ error: describeError(error),
101
+ });
102
+ return [];
103
+ }
104
+ const workers = record(options['application-runtime']).workers;
105
+ if (typeof workers !== 'number' || workers <= 1)
106
+ return [];
107
+ const redis = Object.keys(record(record(options['dokebi-redis']).connections))[0];
108
+ const problems = kept(installed, options).map((item) => ({
109
+ rule: RULE,
110
+ file: CONFIG_FILE,
111
+ line: optionLine(source, ['options', ...item.path]),
112
+ message: `${workers} workers keep their own ${item.what} in process memory: each worker would see only its own, and the plugin refuses to start this way in a cluster worker and in production (D-0161)`,
113
+ fix: item.fix(redis),
114
+ }));
115
+ debug('check: shared-state rule done', { workers, problems: problems.length });
116
+ return problems;
117
+ }
@@ -1,5 +1,6 @@
1
+ import { type ErrorList } from './error-rules.js';
1
2
  import type { DokebiImports } from './packages.js';
2
- import { type Problem } from './problems.js';
3
+ import { type Problem, type RuleName } from './problems.js';
3
4
  import { type Kinds, type TsNode, type TsSourceFile, type TypeScriptProject } from './typescript.js';
4
5
  interface Marker {
5
6
  readonly line: number;
@@ -14,14 +15,36 @@ export interface FloatingCandidate {
14
15
  readonly expression: TsNode;
15
16
  readonly problem: Problem;
16
17
  }
18
+ /**
19
+ * D-0120 §1.4: the names one import of a browser file binds; when none of them has a type the
20
+ * module resolved to nothing and floating-promise cannot judge its calls (a warning).
21
+ */
22
+ export interface UntypedCandidate {
23
+ readonly names: readonly TsNode[];
24
+ readonly problem: Problem;
25
+ }
17
26
  export interface FileFindings {
18
27
  readonly problems: Problem[];
19
28
  readonly candidates: FloatingCandidate[];
29
+ /** Browser files only (D-0120 §1.4). */
30
+ readonly imports?: UntypedCandidate[];
20
31
  }
21
32
  /** Walks one file: all syntactic problems, and the statements the checker must judge. */
22
- export declare function inspectFile(file: TsSourceFile, kinds: Kinds, root: string, imports: DokebiImports): Promise<FileFindings>;
33
+ export declare function inspectFile(file: TsSourceFile, kinds: Kinds, root: string, imports: DokebiImports,
34
+ /** Installed plugin ids: the security rules run only for the plugin they protect. */
35
+ installed?: ReadonlySet<string>,
36
+ /** The codes of src/shared/errors.ts (D-0077 D); unknown-error-code runs only with them. */
37
+ errorList?: ErrorList,
38
+ /**
39
+ * D-0120: a virtual browser source — the file problems name, and the only rules that run
40
+ * (the per-plugin, server and import rules do not apply to browser code).
41
+ */
42
+ browser?: {
43
+ readonly path: string;
44
+ readonly rules: ReadonlySet<RuleName>;
45
+ }): Promise<FileFindings>;
23
46
  /** Every program file: syntactic problems, then one batched checker call for the candidates. */
24
- export declare function sourceProblems(typescript: TypeScriptProject, root: string, imports: DokebiImports, skip: (path: string) => boolean): Promise<{
47
+ export declare function sourceProblems(typescript: TypeScriptProject, root: string, imports: DokebiImports, skip: (path: string) => boolean, installed?: ReadonlySet<string>): Promise<{
25
48
  problems: Problem[];
26
49
  files: number;
27
50
  }>;