arcane-os 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.1
4
+
5
+ - Add a mail CLI parameter table with command scope, purpose, and defaults.
6
+ Explain the difference between `--app`, `X-Mail-App`, `--report-stdin`, and
7
+ the nonsecret `--report-key` used for intentional same-message retries.
8
+ - Remove redundant default-profile configuration and the alternate-account
9
+ placeholder from the main mail example. Align the CLI reference with the
10
+ shared configuration and credential files while preserving named-profile APIs.
11
+ - Clarify when subscription headers are required, how ordinary delivery failures
12
+ retain their existing report, and the separate browser callback and verification
13
+ service paths that can create feedback. Runtime behavior is unchanged.
14
+
15
+ ## 0.24.0
16
+
17
+ - Read nonsecret mail settings from `arcane.config.json.mail` and provider keys
18
+ from `.arcane.env.json.mail`. Keep existing root keys, exact named profiles,
19
+ and TLS path settings working without migrating or rewriting either file.
20
+ - Let explicit CLI/API options override configuration, replace origin lists,
21
+ and apply listener defaults after file settings. Share configured profile,
22
+ sender and provider deadlines with `mail send` without requiring TLS paths.
23
+ - Preserve unrelated settings during credential updates and remove both selected
24
+ key representations on explicit deletion. Document configuration precedence,
25
+ origin rejection, startup, platform behavior and the purpose-gate review.
26
+
3
27
  ## 0.23.0
4
28
 
5
29
  - Rename the mail configuration file to `.arcane.env.json`. Upgrade existing
package/README.md CHANGED
@@ -19,15 +19,17 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.23.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.24.1` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
26
- The [mail gateway](docs/reference/mail.md) serves HTTPS with HTTP/2 on port 4433,
27
- using certificate paths from `.arcane.env.json` in the command's working
28
- directory, and defaults browser mail to
29
- `/v1/mail` on the current domain. Multiple applications can share
30
- one server with explicit allowed origins. Subscription verification is disabled
26
+ The [mail gateway](docs/reference/mail.md) serves HTTPS with HTTP/2 on port 4433
27
+ by default. Configure its host, port, origin list, certificate paths, and other
28
+ mail settings in `arcane.config.json.mail`; keep provider keys in the ignored
29
+ `.arcane.env.json.mail`. Both files belong in the command's working directory.
30
+ Existing root credential, profile, and TLS settings remain supported. Browser
31
+ mail defaults to `/v1/mail` on the current domain, and multiple applications can
32
+ share one server with explicit allowed origins. Subscription verification is disabled
31
33
  until a `verifySubscription` callback is configured; that callback receives the
32
34
  application name and bearer subscription key before each provider attempt.
33
35
  See the [method and action gate report](docs/reviews/mail-server-purpose-review.md)
@@ -812,10 +812,13 @@ npm exec -- arcane repo status
812
812
 
813
813
  ## `arcane mail`
814
814
 
815
- ### Resend credential profiles
815
+ ### Configuration and provider credentials
816
816
 
817
- The mail commands read `.arcane.env.json` from the invocation directory on Windows,
818
- Linux, and macOS. The credential subcommands select one profile in that file:
817
+ Mail reads nonsecret settings from `arcane.config.json.mail` and the Resend key
818
+ from `.arcane.env.json.mail.apiKey` in the invocation directory on Windows,
819
+ Linux, and macOS. See [Mail CLI parameters](mail.md#mail-cli-parameters) for the
820
+ complete parameter, configuration-field, default, and precedence reference.
821
+ The credential subcommands manage the provider key:
819
822
 
820
823
  ```text
821
824
  arcane mail key set [profile] [--secret-stdin]
@@ -826,26 +829,43 @@ arcane mail key delete [profile]
826
829
  `key set` reads the Resend API key from a hidden terminal prompt. The
827
830
  `--secret-stdin` form is for deliberately redirected non-interactive input and
828
831
  rejects a TTY before reading. The key is written to `.arcane.env.json` and is never
829
- accepted in argv or returned in status output. The optional profile defaults
830
- to `mail`, which selects top-level `RESEND_API_KEY`. Any other exact profile
831
- selects `MAIL_PROFILES[profile].RESEND_API_KEY`, with no default-key fallback.
832
+ accepted in argv or returned in status output. The optional credential-command
833
+ profile defaults to `mail`, independently of the send/serve configuration.
832
834
 
833
- The minimal file is:
835
+ The minimal `.arcane.env.json` is:
834
836
 
835
837
  ```json
836
838
  {
837
- "RESEND_API_KEY": ""
839
+ "mail": {
840
+ "apiKey": ""
841
+ }
838
842
  }
839
843
  ```
840
844
 
841
845
  Fill in the key before starting mail, and add `.arcane.env.json` to the project's
842
- `.gitignore`; the SDK repository already ignores it. Set and delete preserve
843
- the file's other settings and profiles. Status returns the selected profile,
846
+ `.gitignore`; the SDK repository already ignores it. Keep nonsecret listener
847
+ settings, including certificate file paths, in `arcane.config.json.mail`.
848
+
849
+ Named profiles remain an optional CLI/toolchain compatibility feature for
850
+ selecting another stored Resend key. An exact name selects
851
+ `mail.profiles[name].apiKey`; the name is independent of incoming application
852
+ names, domains, and subscription keys. One server process uses one selected
853
+ Resend key for all its requests. An absent named key never falls back to the
854
+ default account. Existing root `RESEND_API_KEY` and
855
+ `MAIL_PROFILES[name].RESEND_API_KEY` remain fallbacks when the corresponding
856
+ nested key property is absent. A nested property containing null or an empty
857
+ string means the selected key is absent and takes precedence over a legacy key.
858
+
859
+ Set and delete preserve the file's other settings and profile containers. New
860
+ keys are written to the nested mail member. Existing legacy keys are updated
861
+ in place unless the selected nested key property exists; delete removes both
862
+ representations of only the selected key. Status returns the selected profile,
844
863
  `provider:'resend'`, `storage:'.arcane.env.json'`, and `exists`. Delete returns
845
864
  `exists:false` for both a removed and an already-absent credential.
846
865
 
847
- Programmatic `createToolchain().mail(...)` resolves the configuration directory
848
- from `cwd ?? workspaceRoot ?? process.cwd()`. This uses ordinary Node file
866
+ Programmatic `createToolchain().mail(...)` resolves both files from the directory
867
+ selected by `cwd ?? workspaceRoot ?? process.cwd()`, with no upward search.
868
+ This uses ordinary Node file
849
869
  access rather than platform-specific credential processes. An Android host
850
870
  supplies a compatible Node runtime and an accessible configuration directory.
851
871
  Existing Windows Credential Manager records remain untouched; the JSON reader
@@ -854,11 +874,13 @@ populate or depend on process environment variables for this key.
854
874
 
855
875
  The SDK's installation directory does not affect this location. With an SDK
856
876
  under `my-site/arcane-os-sdk/`, run the command from `my-site/` and keep
857
- `my-site/.arcane.env.json` alongside that directory. Existing deployments using
877
+ `my-site/arcane.config.json` and `my-site/.arcane.env.json` alongside that
878
+ directory. Existing deployments using
858
879
  SDK 0.22.1 or earlier must rename `.env.json` to `.arcane.env.json` while
859
880
  preserving its contents; the loader reads only the new name.
860
881
 
861
- Missing files or missing/empty selected keys stop `send` and `serve` with the
882
+ Each file is an optional configuration source. Missing or empty selected keys
883
+ stop `send` and `serve` with the
862
884
  configuration path and exact JSON setting to fill in. Invalid JSON and file
863
885
  access failures remain observable without printing credential content.
864
886
 
@@ -890,11 +912,16 @@ shape:
890
912
 
891
913
  The CLI forwards the complete provider fields, including template requests.
892
914
  Resend owns their accepted shape. The adapter removes the application-only
893
- `type` field and applies `--from` when supplied; otherwise the report or provider
894
- template supplies the sender. Direct CLI sending has
895
- no configured fallback recipients. The Resend credential comes only from the
896
- selected `.arcane.env.json` profile; omitting `--profile` selects `mail`. Neither the
897
- key nor report content is accepted through argv or process environment variables.
915
+ `type` field and applies the shared sender from `--from` or `mail.from` when
916
+ supplied; otherwise the report or provider template supplies the sender. Direct
917
+ CLI sending has no configured fallback recipients and requires no listener TLS
918
+ paths. It consumes the configured provider timeout and retry guidance.
919
+
920
+ The Resend credential comes from the selected `.arcane.env.json` entry.
921
+ An explicit `--profile` overrides `arcane.config.json.mail.profile`; omitting
922
+ both selects the default `mail.apiKey`, with the legacy fallback described
923
+ above. Neither the key nor report content is accepted through argv or process
924
+ environment variables.
898
925
 
899
926
  The caller owns the nonempty `--report-key`, which is forwarded unchanged.
900
927
  Reuse the same key only with the same
@@ -918,22 +945,28 @@ loss after the attempt begins is ambiguous because Resend may have accepted it.
918
945
  arcane mail serve [--profile <profile>] [--from <verified-sender>] [--app <label>] [--origin <exact-origin>] [--allow-to <addresses>] [--host 0.0.0.0] [--port 4433] [--request-timeout <ms>]
919
946
  ```
920
947
 
921
- The selected `.arcane.env.json` profile supplies only the server-side Resend API key;
922
- omitting `--profile` selects `mail`.
948
+ The selected `.arcane.env.json` entry supplies only the server-side Resend API
949
+ key. An explicit `--profile` overrides `arcane.config.json.mail.profile`;
950
+ omitting both selects the default key.
923
951
 
924
- Add the listener's certificate configuration at the top level of the same file:
952
+ Add the listener's certificate paths to `arcane.config.json`:
925
953
 
926
954
  ```json
927
955
  {
928
- "RESEND_API_KEY": "",
929
- "MAIL_TLS_CERT_PATH": ".arcane/mail/fullchain.pem",
930
- "MAIL_TLS_KEY_PATH": ".arcane/mail/private-key.pem"
956
+ "mail": {
957
+ "certPath": ".arcane/mail/fullchain.pem",
958
+ "keyPath": ".arcane/mail/private-key.pem"
959
+ }
931
960
  }
932
961
  ```
933
962
 
934
963
  Supply an existing PEM certificate chain and its private key. Paths resolve
935
- relative to `.arcane.env.json`, or may be absolute. They are shared across provider
936
- profiles. Missing TLS settings name the fields to fill in before a listener
964
+ relative to the selected configuration directory, or may be absolute. They
965
+ belong to the listener regardless of the provider key selected. Legacy root
966
+ `MAIL_TLS_CERT_PATH` and `MAIL_TLS_KEY_PATH` in `.arcane.env.json` remain
967
+ fallbacks for omitted config paths. Explicit programmatic `certPath` and
968
+ `keyPath` options override those files. Missing TLS settings name the fields
969
+ to fill in before a listener
937
970
  opens; the TLS owner reports PEM file errors. Keep private-key material outside
938
971
  tracked source. The SDK repository already ignores `.arcane/` and `.arcane.env.json`.
939
972
 
@@ -949,7 +982,7 @@ server; the incoming request's `X-Mail-App` identifies the application for
949
982
  subscription verification. The HTTP authentication contract pairs that
950
983
  application with `Authorization: Bearer <subscription_key>`.
951
984
 
952
- Subscription verification is disabled for this initial service setup. The
985
+ Subscription verification is disabled when no callback is configured. The
953
986
  programmatic `createToolchain().mail({action: 'serve', ...})` path accepts
954
987
  `verifySubscription({appName, subscriptionKey, signal})`; supplying that callback
955
988
  enables verification before each provider attempt. It must resolve to `true`
@@ -958,16 +991,27 @@ failure receives retryable 503; cancellation stops verification before sending.
958
991
  The callback connects the actual TWiN Stripe endpoint when its contract is
959
992
  ready. There is no guessed URL, response schema, or command-line endpoint flag.
960
993
 
961
- The listener defaults to `0.0.0.0`; `--host` selects another bind host. Browser
962
- mail defaults to `/v1/mail` on the current domain. `--origin` is optional and
963
- selects an explicit CORS allowlist when supplied. `--allow-to` optionally
964
- supplies a comma-separated recipient allowlist. CLI parsing preserves supplied
965
- address spelling and repeated entries. Programmatic `errorTo` selects fallback
966
- recipients for error reports; when omitted, the selected `allowTo` list supplies
967
- that fallback. `--request-timeout`
968
- adds a caller-selected provider-attempt timeout from 1 through 2147483647
969
- milliseconds, the Node timer range. When it is omitted, the SDK adds no
970
- provider timeout.
994
+ The listener defaults to `0.0.0.0:4433`; `mail.host` and `mail.port` configure
995
+ the listener, and explicit `--host` and `--port` override them. Browser mail
996
+ defaults to `/v1/mail` on the current domain. `mail.origins` supplies an exact
997
+ origin array; `--origin` replaces it with one origin. Repeated `--origin`
998
+ options keep the last value. An absent or empty list selects the current
999
+ request authority. Other Origins receive `403 mail_origin_not_allowed`;
1000
+ requests without an Origin continue normally. This CORS configuration does
1001
+ not filter client IPs or give loopback special treatment.
1002
+
1003
+ `mail.recipientAllowlist` supplies the recipient list; `--allow-to` replaces it
1004
+ with a comma-separated list. CLI parsing preserves supplied address spelling
1005
+ and repeated entries. Programmatic `errorTo` or `mail.errorRecipients` selects
1006
+ fallback recipients for error reports. When neither is supplied, the effective
1007
+ recipient allowlist supplies that fallback; an explicit empty array supplies
1008
+ none. The [Mail CLI parameters](mail.md#mail-cli-parameters) reference also
1009
+ documents programmatic aliases and list-replacement precedence.
1010
+
1011
+ `--request-timeout` overrides `mail.providerTimeoutMs` and accepts 1 through
1012
+ 2147483647 milliseconds, the Node timer range. An absent effective timeout
1013
+ adds no provider deadline; programmatic/configured null also selects no
1014
+ deadline. `mail.bodyTimeoutMs` selects the optional request-body deadline.
971
1015
 
972
1016
  After binding, `server.ready` reports lifecycle fields such as
973
1017
  protocol, optional app label, bind address, port, URL, and `callerAuthentication`
@@ -272,51 +272,154 @@ committed acceptance result.
272
272
 
273
273
  ## Operate the CLI and gateway
274
274
 
275
- Create `.arcane.env.json` in the directory from which the mail command runs, then fill
276
- in the provider key and the HTTPS certificate paths:
275
+ ### Mail CLI parameters
276
+
277
+ `mail serve` starts the shared mail server. `mail send` makes one direct Resend
278
+ request. `mail key set|status|delete` manages the provider credential.
279
+
280
+ | Parameter | Command | What it does | When omitted |
281
+ | --- | --- | --- | --- |
282
+ | `--app <label>` | `mail serve` | Supplies a fallback application label for server events. An incoming `X-Mail-App` supplies the request's label. This option does not choose a Stripe account or limit which apps may send mail. | Uses `mail.appId` when configured; otherwise adds no fallback label. |
283
+ | `--from <sender>` | `mail serve`, `mail send` | Overrides the sender, for example `"Dragon Dispatch <dispatch@example.com>"`. On the server, the override applies to every report. Resend must accept that sender. | Uses `mail.from` when configured, then the report's `from` or provider template default. |
284
+ | `--report-stdin` | `mail send` | Reads one complete UTF-8 report JSON object from redirected standard input, including recipients, subject, body, and other provider fields. | Required; the command reports a usage error. |
285
+ | `--report-key <id>` | `mail send` | Identifies one intended email and is forwarded unchanged as Resend's `Idempotency-Key`. Use a new value for a new email; reuse the value and unchanged report for an intentional retry. It is not a credential. | Required; the command reports a usage error. |
286
+ | `--host <address>` | `mail serve` | Selects the listener bind address. `0.0.0.0` accepts connections through all IPv4 interfaces. | Uses `mail.host`, then `0.0.0.0`. |
287
+ | `--port <number>` | `mail serve` | Selects the HTTPS listener port. Explicit `0` asks the operating system for an available port. | Uses `mail.port`, then `4433`. |
288
+ | `--origin <origin>` | `mail serve` | Replaces the configured CORS origins with one exact browser origin, including scheme and any nondefault port. Use the JSON `origins` array for multiple origins; repeated flags keep only the last value. | Uses `mail.origins`; an absent or empty list uses the current request authority. Requests without an `Origin` header continue normally. |
289
+ | `--allow-to <addresses>` | `mail serve` | Replaces the recipient allowlist with a comma-separated list. Applies to `to`, `cc`, and `bcc`, and supplies error-report fallback recipients unless `mail.errorRecipients` is configured separately. | Uses `mail.recipientAllowlist`; an absent or empty list leaves recipients unrestricted. |
290
+ | `--request-timeout <ms>` | `mail serve`, `mail send` | Sets the deadline for one Resend request, in milliseconds. A timeout after sending begins leaves delivery uncertain; it does not schedule a retry. | Uses `mail.providerTimeoutMs`; absent or null adds no provider deadline. |
291
+ | `--profile <name>` | `mail serve`, `mail send` | Selects an alternate saved Resend credential for this invocation. One listener uses that selected provider key for all callers. | Uses `mail.profile` if configured, otherwise the normal `mail.apiKey`. The single-key setup needs no profile setting. |
292
+ | `[profile]` | `mail key set\|status\|delete` | Optional positional name of the credential to manage; it is not a flag. | Manages the normal `mail.apiKey`, independently of `mail.profile`. |
293
+ | `--secret-stdin` | `mail key set` | Reads the Resend API key from deliberately redirected standard input instead of the hidden terminal prompt. | Prompts with hidden input. Machine-output mode requires redirected input. |
294
+ | `--output human\|json\|ndjson` | All mail commands | Selects readable console output, one JSON result, or newline-delimited JSON events. | Uses `human`. |
295
+ | `--help` | CLI | Displays CLI help instead of running the mail operation. | Runs the requested operation. |
296
+ | `--version` | CLI | Displays the SDK version instead of running the mail operation. | Runs the requested operation. |
297
+
298
+ There is no `--report` flag. `--report-stdin` supplies the message;
299
+ `--report-key` identifies that same message across attempts. Browser `Mail.send()`
300
+ generates and retains its report key automatically.
301
+
302
+ Mail reads configuration from the directory in which the command runs;
303
+ `--workspace` does not relocate these mail files. Mail HTTPS uses `mail.certPath`
304
+ and `mail.keyPath` in JSON. The `--cert`, `--key`, `--https`, and `--http-port`
305
+ flags belong to browser `dev`/`run`, and `--http` belongs to `dev`.
306
+
307
+ ### Mail configuration
308
+
309
+ Keep app-supplied SDK settings under named capability members, starting with
310
+ `mail`. Put the nonsecret mail settings in `arcane.config.json` in the directory
311
+ from which the command runs:
277
312
 
278
313
  ```json
279
314
  {
280
- "RESEND_API_KEY": "",
281
- "MAIL_TLS_CERT_PATH": "",
282
- "MAIL_TLS_KEY_PATH": ""
315
+ "mail": {
316
+ "host": "0.0.0.0",
317
+ "port": 4433,
318
+ "origins": [
319
+ "https://dragons.example",
320
+ "https://www.dragons.example"
321
+ ],
322
+ "certPath": "certificates/fullchain.pem",
323
+ "keyPath": "certificates/private-key.pem"
324
+ }
283
325
  }
284
326
  ```
285
327
 
286
- The SDK repository ignores `.arcane.env.json`. Keep the same entry in a consuming
287
- project's `.gitignore`. This is a JSON configuration file; the mail commands
288
- read it directly without copying its contents into `process.env`.
289
-
290
- The default profile is `mail`, which selects top-level `RESEND_API_KEY`.
291
- The explicit `--profile mail` form selects the same setting. Other profile names
292
- select exact entries under `MAIL_PROFILES`:
328
+ Put the Resend provider key in the separate `.arcane.env.json`:
293
329
 
294
330
  ```json
295
331
  {
296
- "RESEND_API_KEY": "",
297
- "MAIL_PROFILES": {
298
- "another-provider-account": {
299
- "RESEND_API_KEY": ""
300
- }
332
+ "mail": {
333
+ "apiKey": ""
301
334
  }
302
335
  }
303
336
  ```
304
337
 
305
- An absent named profile does not fall back to the default key. The profile
306
- selects Resend provider credentials; it is separate from the incoming
307
- application name and subscriber key.
338
+ The SDK repository ignores `.arcane.env.json`. Keep the same entry in a consuming
339
+ project's `.gitignore`. `arcane.config.json` contains settings suitable for source
340
+ control; its certificate fields contain file paths, never PEM contents or provider
341
+ keys. Both files are read directly as JSON without copying values into
342
+ `process.env`. Other top-level capability members remain untouched. The portable
343
+ browser `arcane-os/mail` import does not read these Node-side files.
344
+
345
+ The supported `arcane.config.json.mail` fields are:
308
346
 
309
- Programmatic operations resolve `.arcane.env.json` from `options.cwd`, then
347
+ | Field | Type | Purpose and default |
348
+ | --- | --- | --- |
349
+ | `host` | string | Listener bind address; defaults to `0.0.0.0`. |
350
+ | `port` | integer | Listener port; defaults to `4433`. Explicit `0` selects an available port. |
351
+ | `origins` | string array | Exact allowed browser origins. An absent or empty list uses the current request authority as described below. |
352
+ | `profile` | string | Resend credential profile for send/serve; defaults to `mail`. |
353
+ | `from` | string | Optional shared sender override. Omit it to retain each report's sender or provider template default. |
354
+ | `appId` | string | Optional server event label; does not restrict incoming application names. |
355
+ | `recipientAllowlist` | string array | Optional allowed recipients; absent or empty means unrestricted recipients. |
356
+ | `errorRecipients` | string array | Error-report fallback recipients; defaults to the effective recipient allowlist. An explicit empty array supplies no fallback. |
357
+ | `bodyTimeoutMs` | integer or null | Optional request-body deadline in milliseconds; absent or null adds no deadline. |
358
+ | `providerTimeoutMs` | integer or null | Optional provider deadline in milliseconds; absent or null adds no deadline. |
359
+ | `retryableDelayMs` | positive integer | Retry guidance in a retryable result; defaults to `1000`. It does not schedule a retry. |
360
+ | `certPath` | string | PEM certificate-chain file path, required for gateway HTTPS. |
361
+ | `keyPath` | string | PEM private-key file path, required for gateway HTTPS. |
362
+
363
+ Resend is the supported provider; no provider selector is needed. Keep callbacks,
364
+ injected providers, `fetchImpl`, `onEvent`, `requestIdFactory`,
365
+ `verifySubscription`, and `AbortSignal` values in programmatic options. They are
366
+ runtime inputs, not JSON settings. Reports and their idempotency keys remain
367
+ inputs to each send operation.
368
+
369
+ The single-key configuration above needs no `profile` or `profiles` member.
370
+ Omitting the selection uses `.arcane.env.json.mail.apiKey`; multiple apps and
371
+ domains can share that provider key.
372
+
373
+ For callers that already select an alternate credential, named-profile support
374
+ remains available: `--profile <name>` or `mail.profile` selects
375
+ `.arcane.env.json.mail.profiles[name].apiKey`. An absent named profile does not
376
+ fall back to the default key. Selection happens once when send or serve starts;
377
+ it does not map incoming apps or domains to different provider accounts.
378
+ The profile selects Resend provider credentials, separately from the incoming
379
+ application name and subscriber key. Existing top-level `RESEND_API_KEY` and
380
+ `MAIL_PROFILES[profile].RESEND_API_KEY` remain supported. A nested selected
381
+ `apiKey` takes precedence when the property exists, including null or an empty
382
+ string, which means the selected key is absent. Only an absent nested key
383
+ property permits fallback to the corresponding legacy key.
384
+
385
+ Programmatic operations resolve both files from `options.cwd`, then
310
386
  `options.workspaceRoot`, then `process.cwd()`, choosing the first supplied
311
387
  directory. The CLI uses its invocation directory. There is no upward directory
312
388
  search or dependency on a Windows installation directory or temporary-directory
313
- environment variable.
389
+ environment variable. Missing files are optional configuration sources; send and
390
+ serve still report their missing required values before attempting delivery or
391
+ binding. Malformed or unreadable files produce an error.
392
+
393
+ Configuration precedence is explicit:
394
+
395
+ 1. A CLI/API option overrides its file setting when its value is not `undefined`.
396
+ An explicit null retains the option's existing meaning; it does not select
397
+ the file value again.
398
+ 2. `arcane.config.json.mail` supplies nonsecret settings absent from those options.
399
+ 3. Legacy `.arcane.env.json` root `MAIL_TLS_CERT_PATH` and `MAIL_TLS_KEY_PATH`
400
+ supply certificate paths absent from the selected options and config member.
401
+ 4. Remaining settings use the defaults above.
402
+
403
+ The existing programmatic aliases `origin`, `allowTo`, `errorTo`, and
404
+ `requestTimeout` take precedence over their corresponding canonical options
405
+ `origins`, `recipientAllowlist`, `errorRecipients`, and `providerTimeoutMs` when
406
+ both are supplied. The CLI continues to expose `--origin`, `--allow-to`, and
407
+ `--request-timeout`. `origin` accepts a string or an array; the recipient aliases
408
+ accept address arrays or comma-separated strings. Lists replace the lower-priority
409
+ list completely. The configuration reader does not concatenate or deduplicate
410
+ lists, rewrite case, or automatically add local addresses.
411
+
412
+ Use the JSON `origins` array for multiple origins. Repeating the current
413
+ `--origin` option keeps only its last value; there is no `--origins` CLI option.
414
+ The CLI leaves omitted host, port, and send/serve profile options unset until
415
+ configuration resolves, so its defaults do not mask file settings.
314
416
 
315
417
  Keep the configuration in the deployment directory even when the SDK is nested
316
418
  below it:
317
419
 
318
420
  ```text
319
421
  my-site/
422
+ ├── arcane.config.json
320
423
  ├── .arcane.env.json
321
424
  └── arcane-os-sdk/
322
425
  └── bin/arcane.mjs
@@ -325,13 +428,15 @@ my-site/
325
428
  Run from `my-site`, for example:
326
429
 
327
430
  ```sh
328
- node ./arcane-os-sdk/bin/arcane.mjs mail serve --port 4433
431
+ node ./arcane-os-sdk/bin/arcane.mjs mail serve
329
432
  ```
330
433
 
331
434
  The SDK directory does not choose the configuration location. When upgrading
332
435
  from SDK 0.22.1 or earlier, rename the existing `.env.json` to
333
436
  `.arcane.env.json` in the invocation directory, preserving its contents.
334
- The loader reads only `.arcane.env.json`.
437
+ The secret loader reads only `.arcane.env.json`; it does not read the old filename.
438
+ Existing root key, profile, and TLS fields can remain in that file. Adopting the
439
+ capability members does not perform an automatic rewrite or migration.
335
440
 
336
441
  The existing key commands manage the same file:
337
442
 
@@ -343,8 +448,14 @@ arcane mail key delete
343
448
 
344
449
  `key set` prompts with hidden input. `--secret-stdin` is the explicit
345
450
  non-interactive alternative and rejects a TTY. Each command accepts an optional
346
- profile argument, defaulting to `mail`. Set and delete preserve other JSON
347
- settings and profiles; status reports existence without returning the key.
451
+ profile argument, defaulting to `mail` independently of `arcane.config.json.mail.profile`.
452
+ Set and delete preserve other JSON settings and profiles; status reports
453
+ existence without returning the key. A new credential is written to the nested
454
+ mail member. An existing legacy credential is updated at its existing location
455
+ unless the selected nested key property exists, in which case set updates that
456
+ nested property. Delete removes both representations of only the selected key,
457
+ so an older key cannot reappear through fallback. Other settings and profile
458
+ containers remain intact.
348
459
  Results identify `storage: '.arcane.env.json'`. An already-absent deletion succeeds
349
460
  with `exists: false`.
350
461
 
@@ -363,8 +474,8 @@ arcane mail send --from "Arcane <verified@example.com>" --report-key <stable-id>
363
474
  The redirected UTF-8 JSON object is read completely. Its fields and values
364
475
  are retained; the Resend adapter removes the SDK's `type` routing field,
365
476
  uses `from` when configured (otherwise the report or provider template supplies
366
- the sender), and applies configured error-recipient fallback
367
- when applicable. Resend evaluates its own required provider fields. Message
477
+ the sender). Direct sending supplies no fallback recipients. Resend evaluates
478
+ its own required provider fields. Message
368
479
  content is not accepted in argv. Programmatic results and observer events
369
480
  preserve the complete report, provider request, provider response, and error detail while
370
481
  never exposing either credential.
@@ -379,19 +490,28 @@ cancellation after the provider attempt begins is returned as an ambiguous
379
490
  nonzero outcome because the provider may already have accepted the request.
380
491
  Cancellation before the attempt exits 130 without sending.
381
492
  For both CLI mail operations, `--request-timeout` accepts 1 through 2147483647
382
- milliseconds, the Node timer range. When omitted, the SDK adds no provider
383
- deadline.
493
+ milliseconds, the Node timer range. The same range applies to configured body
494
+ and provider deadlines. When no provider timeout is selected in options or
495
+ configuration, the SDK adds no provider deadline.
496
+
497
+ `mail send` consumes the selected profile, sender, provider timeout, and retry
498
+ guidance from the same configuration. It does not require gateway TLS paths.
499
+ Send and serve read each required JSON file once, concurrently when both are
500
+ needed, before consuming their settings. An injected `readCredential` remains
501
+ the credential owner and reads once. With that injection, send reads only
502
+ `arcane.config.json`; serve also reads `.arcane.env.json` for legacy TLS paths
503
+ without interpreting its unused file credential.
384
504
 
385
505
  Start the gateway:
386
506
 
387
507
  ```text
388
- npm exec -- arcane mail serve --profile mail --host 0.0.0.0 --port 4433
508
+ npm exec -- arcane mail serve
389
509
  ```
390
510
 
391
- The default listener is `0.0.0.0:4433`; `--host` and `--port` select its bind
392
- address and port. The server can serve callers from multiple domains on the
393
- same machine. Route the page's `/v1/mail` to this listener, or configure an
394
- explicit shared endpoint in the caller.
511
+ The default listener is `0.0.0.0:4433`; `mail.host` and `mail.port` select its
512
+ configured bind address and port, and explicit `--host` / `--port` override them.
513
+ The server can serve callers from multiple domains on the same machine.
514
+ Configure the caller's endpoint to reach that listener.
395
515
 
396
516
  `mail serve` uses HTTPS with HTTP/2 on that selected port. The published
397
517
  `node-http-server` PEM API owns TLS and negotiates HTTP/2 or HTTP/1.1 on the
@@ -399,25 +519,29 @@ same listener. It creates no additional plain-HTTP listener. The returned URL
399
519
  uses `https://`; `0.0.0.0` is the bind address, so callers use the deployed
400
520
  domain, for example `https://mail.example.com:4433/v1/mail`.
401
521
 
402
- Set `MAIL_TLS_CERT_PATH` to the PEM certificate chain and `MAIL_TLS_KEY_PATH`
403
- to its PEM private-key file. These top-level settings belong to the listener
404
- and apply regardless of the selected provider profile. Relative paths resolve
405
- from the directory containing `.arcane.env.json`; absolute paths are also accepted.
522
+ Set `arcane.config.json.mail.certPath` to the PEM certificate chain and
523
+ `mail.keyPath` to its PEM private-key file. These settings belong to the listener
524
+ and apply regardless of the selected provider profile. The legacy root
525
+ `MAIL_TLS_CERT_PATH` and `MAIL_TLS_KEY_PATH` fields in `.arcane.env.json` remain
526
+ fallbacks. Relative certificate paths resolve from the selected configuration
527
+ directory, including explicit programmatic path options; absolute paths are also accepted.
406
528
  The certificate must cover the hostname callers use. One certificate may
407
529
  cover multiple names; the gateway does not require one certificate per calling
408
530
  application. Keep private-key files outside tracked source, such as in the
409
531
  already-ignored `.arcane/` directory or an existing host certificate directory.
410
532
 
411
- Startup reads the JSON configuration once, reports missing TLS settings before
533
+ Startup reads each JSON file once, reports missing TLS settings before
412
534
  binding, and lets the TLS owner report unreadable or unusable PEM files. It
413
535
  does not generate certificates, modify system trust, or add a renewal watcher.
536
+ No JSON configuration file is reread for an incoming HTTP request.
414
537
  Restart the gateway after the configured certificate files are renewed.
415
538
  The same Node file and TLS APIs are used on Windows, Linux, and macOS; Android
416
539
  requires a compatible Node host and accessible configuration and certificate
417
540
  paths. These platform contracts are separate from actual platform execution.
418
541
 
419
542
  The public `createToolchain().mail({action: 'serve', ...options})` operation
420
- uses the same JSON certificate pair. Internally, `startResendMailServer` accepts
543
+ uses the same settings with explicit options taking precedence. Internally,
544
+ `startResendMailServer` accepts
421
545
  `certPath` and `keyPath` and retains its existing HTTP behavior when neither
422
546
  is supplied. That internal function is not an npm package export.
423
547
 
@@ -429,18 +553,25 @@ The existing listener remains running; this launch does not retry or select
429
553
  another port.
430
554
 
431
555
  `--app` is an optional server event label and does not restrict incoming
432
- application names. `--from` is an optional shared sender override; omit it to
433
- preserve each report's sender or its provider template's default.
434
- `--origin` selects an exact allowed caller origin; the
435
- programmatic `origin` option also accepts an array for multiple origins. With
436
- no origins configured, the gateway accepts an Origin matching its request
556
+ application names. `--from` and `mail.from` are optional shared sender overrides;
557
+ omit both to preserve each report's sender or its provider template's default.
558
+ `mail.origins` supplies an array of exact allowed caller origins. An explicit
559
+ `--origin` replaces that array; the programmatic `origin` alias also accepts an
560
+ array. With no origins configured, the gateway accepts an Origin matching its request
437
561
  authority (`:authority` for HTTP/2, `Host` for HTTP/1.1) using HTTP or HTTPS.
438
- Requests without Origin continue normally.
562
+ An Origin outside the configured list or current-authority default receives
563
+ `403 mail_origin_not_allowed`. Origin strings are compared exactly: include the
564
+ scheme and any nondefault port, with no path or trailing slash. There are no
565
+ wildcards, subdomain expansion, normalization, or loopback exceptions.
566
+ Requests without an `Origin` header continue normally. This is a declared-origin
567
+ CORS list, not a client-IP or connecting-machine allowlist; ordinary
568
+ server-to-server requests commonly omit Origin.
439
569
  Cross-origin preflight permits `Content-Type`, `Idempotency-Key`, `X-Mail-App`,
440
- and `Authorization`. This is origin configuration, not a loopback policy.
570
+ and `Authorization`.
441
571
 
442
- `--allow-to` explicitly limits recipients when supplied. With it omitted, the
443
- gateway imposes no recipient allowlist. When configured, the list applies to
572
+ `mail.recipientAllowlist` or explicit `--allow-to` limits recipients when
573
+ configured. An absent or empty effective list imposes no recipient allowlist.
574
+ When configured, the list applies to
444
575
  every recipient in the resolved `to`, `cc`, and `bcc` fields, whether supplied
445
576
  as a string or an array. Sender, recipient, subject, and body
446
577
  values are not trimmed, lowercased, or filtered by an SDK email grammar at
@@ -473,6 +604,12 @@ key, and `signal` follows the request lifecycle. These control fields stay
473
604
  separate from the mail report and Resend payload. The verifier runs for each
474
605
  POST request before any provider attempt; results are not cached.
475
606
 
607
+ `X-Mail-App` identifies the originating app so the verifier can select that
608
+ app's subscription account, such as the appropriate Stripe account for `BOSS`
609
+ or `TWiN`. It is separate from the gateway's optional `--app` event label.
610
+ The missing-header and Bearer errors below apply only when `verifySubscription`
611
+ is configured. Ordinary `mail serve` does not require either header.
612
+
476
613
  | Configured-verifier outcome | Gateway response |
477
614
  | --- | --- |
478
615
  | Missing or empty `X-Mail-App` | `400 mail_invalid_headers` |
@@ -496,6 +633,32 @@ CLI startup output says `Subscription verification: disabled` or
496
633
  `Subscription verification: configured`. Structured `server.ready` output
497
634
  includes the corresponding `callerAuthentication` value.
498
635
 
636
+ ## Error reports and retries
637
+
638
+ An incoming report with `type: 'error'` is an email supplied by its caller.
639
+ The gateway's `errorRecipients` setting only supplies fallback recipients for
640
+ that report when its `to` array is empty. A gateway or provider failure returns
641
+ an HTTP error and diagnostic events; it does not generate another error email.
642
+ Gateway event-handler failures are written to the console. Each POST and each
643
+ direct CLI send performs at most one provider attempt, with no automatic server retry.
644
+
645
+ The browser outbox retains the same report and key after a retryable delivery
646
+ failure. Each drain attempts each eligible record once; a later startup,
647
+ connectivity event, or explicit drain can retry it. Browser `Errors` notification
648
+ failure retains the same pending occurrence and logs a warning without scheduling
649
+ itself again.
650
+
651
+ A separate browser callback path can feed back into error reporting: a mail-event
652
+ subscriber that repeatedly throws can reach global error capture, create another
653
+ error report, and trigger another mail event. Subscriber failures cannot change
654
+ an already committed mail result, though global capture can report them as new
655
+ errors. This is a source-level possibility, not evidence of a deployed loop.
656
+
657
+ For a verification service that sends its own emails, its host integration must
658
+ also avoid the circular request path `mail -> verify subscription -> send mail ->
659
+ verify subscription`. The SDK supplies a callback for verification; it does not
660
+ choose that service's caller flow or enable it in ordinary CLI startup.
661
+
499
662
  ## Gateway request lifecycle
500
663
 
501
664
  The gateway uses the published `node-http-server` instance lifecycle. Its raw
@@ -457,3 +457,87 @@ payloads, profiles, other settings and lifecycle remain unchanged.
457
457
 
458
458
  Existing credential and CLI test fixtures now use the selected name. Source
459
459
  and diff were reviewed; local tests, checks and server execution were not run.
460
+
461
+ ## Capability configuration and secret separation follow-up
462
+
463
+ The selected outcome is one app-authored mail configuration in
464
+ `arcane.config.json.mail`, with Resend credentials in ignored
465
+ `.arcane.env.json.mail`. The SDK owns reading and applying these settings through
466
+ the existing CLI/toolchain mail operation. Domains, senders, certificates, and
467
+ provider accounts remain deployment-owned data. This increment begins the
468
+ named-capability configuration convention with mail; it does not migrate other
469
+ capabilities or consumer files.
470
+
471
+ The source audit found no existing `arcane.config.json` reader to reuse. The
472
+ workspace loader owns `arcane-packager.json`, descriptors, and application
473
+ layout. Requiring that loader to start a mail listener would add unrelated work.
474
+ The existing mail JSON reader is the reusable boundary for the two selected
475
+ files. Their explicit precedence and credential compatibility rules are in the
476
+ [mail reference](../reference/mail.md#operate-the-cli-and-gateway).
477
+
478
+ | Method or action | Gates | Decision, callers, and concrete purpose |
479
+ | --- | --- | --- |
480
+ | `readMailConfiguration` | Y/Y/N | Keep the cohesive configuration at the SDK owner. Send/serve read each required file once, in parallel when both are needed. A send with an injected credential reader skips the unused secrets file; incoming HTTP requests use the resolved configuration without file reads. Other capability settings stay untouched. |
481
+ | CLI's early default host, port, and send/serve profile values | Y/Y/Y | Remove premature default assignment. The same defaults remain after file configuration resolves, while explicit CLI values retain priority. Assigning defaults before reading JSON would conceal the deployment's chosen settings. |
482
+ | `origins` string array and list replacement | Y/Y/N | Keep one exact list for multiple caller domains. Explicit options replace the file list, including empty arrays. Existing `origin`, `allowTo`, `errorTo`, and `requestTimeout` aliases remain compatible and win over their canonical programmatic names when both are supplied. No normalization or list-merging helper is needed. |
483
+ | Root provider-key, named-profile, and TLS compatibility | Y/Y/N | Preserve supported inputs using `RESEND_API_KEY`, `MAIL_PROFILES`, `MAIL_TLS_CERT_PATH`, and `MAIL_TLS_KEY_PATH`. Nested selected key presence takes priority even when null or empty. Named profiles never select a different account's default key. Named-profile deployment use is unverified; the retained behavior is a compatibility contract. |
484
+ | Key set/status/delete operations | Y/Y/N | Preserve credential management and secret-free status. New keys use the nested member; existing legacy keys are updated in place unless a nested key exists. Delete removes both selected representations to prevent an old credential reappearing, preserving other keys, settings, and profile containers. Key commands still default to `mail` independently of the serving profile. |
485
+ | Send configuration | Y/Y/N | Use the same profile, sender, provider-timeout, and retry-guidance settings for direct sending. A send needs provider authority and its report, so listener certificates remain a serve-only prerequisite. Reports and idempotency keys remain per-operation inputs. |
486
+ | Explicit runtime dependencies and callbacks | Y/Y/N | Preserve `readCredential`, provider injection, observation, cancellation, and subscription callbacks as programmatic inputs. A function or signal is not JSON configuration. The portable browser mail import retains its existing dependency boundary. |
487
+ | Shared sender omission | Y/Y/N | Preserve per-report `from` and provider-template sender selection when no shared override is configured. One listener can therefore serve independent application sender identities. |
488
+ | Origin rejection behavior and explanation | Y/Y/N | Document the existing `403 mail_origin_not_allowed`, exact current-authority default, acceptance without Origin, and absence of IP filtering or loopback exceptions. This clarifies the retained behavior without adding admission policy. |
489
+ | New provider selector, per-field helper hierarchy, consumer parser, CLI-generation layer, automatic migration, watcher, and request-time config reads | N/N/Y | Add none. Resend is the sole implemented provider, the selected settings fit one owner, and existing CLI/API options express overrides. These additions would increase work without changing the requested outcome. |
490
+
491
+ The operation graph is at most two asynchronous JSON reads and one selected
492
+ credential per send/serve invocation, followed by its existing provider attempt
493
+ or listener lifecycle. A send with an injected credential reader reads only
494
+ `arcane.config.json` and calls that reader once. Key operations retain one
495
+ credential-file read and a write only when
496
+ the requested set/delete needs one. Certificate contents are still loaded by
497
+ the published HTTP server module. No dependency, process, polling loop, cache,
498
+ or application/host fan-out is introduced. These are source-level work counts,
499
+ not measured performance claims.
500
+
501
+ The compatibility audit traces the CLI into `executeMailCommand`, the public
502
+ `createToolchain().mail(...)` / `executeOperation('mail', ...)` entry points,
503
+ credential CRUD, direct send, and serve. The complete report and provider-result
504
+ paths are preserved. Configuration paths use the same portable Node filesystem
505
+ contract on Windows, Linux, and macOS, with a compatible Node host as the Android
506
+ adaptation. Local tests, checks, server launches, live mail sends, and platform
507
+ execution were not performed by this documentation author. Selected package
508
+ verification and publication outcomes belong to the release owner's delivery
509
+ record.
510
+
511
+ ## Parameter examples and error-report path clarification
512
+
513
+ The ordinary shared mail server needs one Resend key. Its current-domain
514
+ behavior, origin list, and incoming application/subscription verification do
515
+ not select among provider profiles. A named profile selects one stored key
516
+ for an entire send or server invocation. Source inspection found named CLI and
517
+ toolchain inputs plus synthetic tests; it did not establish a deployment that
518
+ uses named mail profiles.
519
+
520
+ | Method or action | Gates | Decision, callers, and concrete purpose |
521
+ | --- | --- | --- |
522
+ | Explicit default `profile: 'mail'` in setup examples | N/N/Y | Remove the redundant setting. Omission already selects the default key when no named configuration is supplied. CLI send/serve still honor an explicitly selected profile or `mail.profile`. |
523
+ | Extra `another-provider-account` credential placeholder | N/N/Y | Remove it from ordinary setup. It adds an unused account choice to a one-key configuration. Existing optional named-profile behavior remains documented without presenting another account as a setup requirement. |
524
+ | Named-profile CLI/toolchain capability and credential CRUD | Y/Y/N for compatibility | Retain the supported optional profile argument and exact named-key operations. Concrete application/deployment use is unverified. This is an account-selection contract, separate from removing redundant default arguments; its retention makes no claim that the nominal deployment needs it. |
525
+ | Full CLI parameter explanation | Y/Y/N | Keep one canonical [Mail CLI parameters](../reference/mail.md#mail-cli-parameters) table. The CLI reference links to it and describes the current split configuration, selected values, defaults, aliases, and provider behavior without another competing table. |
526
+ | Claim that mail errors can never produce an error-report loop | N/N/Y | Replace the absolute claim with the source-path qualification below. Ordinary server failures do not send another mail; a repeatedly failing browser event subscriber can produce new global error incidents during error-mail delivery. |
527
+
528
+ The Node gateway reports ordinary request/provider failures through responses,
529
+ observer results, or its console logger. Its error reporter does not call the
530
+ mail sender. In the browser, `Errors` owns global `error` and
531
+ `unhandledrejection` capture and sends error notifications through Mail. A
532
+ failed notification remains pending with a warning rather than immediately
533
+ creating another notification from that send failure.
534
+
535
+ There is a separate conditional path: a subscriber that repeatedly throws
536
+ while handling Mail events reaches the event authority's listener-failure
537
+ reporter. In browsers with `reportError`, that failure can become a new global
538
+ error incident. Sending that incident through Mail can invoke the same failing
539
+ subscriber again. A loop therefore depends on that subscriber and the active
540
+ global error handler; it is not an automatic server response to an ordinary
541
+ mail failure. This is a static source-path finding, not an observed production
542
+ incident or an executed reproduction. This documentation clarification changes
543
+ no error, subscription, retry, or delivery behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/cli/main.mjs CHANGED
@@ -86,7 +86,7 @@ Usage:
86
86
  ${CLI_NAME} mail key delete [profile]
87
87
  ${CLI_NAME} mail send [--profile <profile>] [--from <address>] --report-key <id> --report-stdin [--request-timeout <ms>]
88
88
  ${CLI_NAME} mail serve [--profile <profile>] [--from <address>] [--app <label>] [--origin <origin>] [--allow-to <addresses>] [--host 0.0.0.0] [--port 4433] [--request-timeout <ms>]
89
- HTTPS/HTTP2; .arcane.env.json supplies RESEND_API_KEY, MAIL_TLS_CERT_PATH, and MAIL_TLS_KEY_PATH.
89
+ HTTPS/HTTP2; arcane.config.json.mail supplies settings; .arcane.env.json supplies keys.
90
90
 
91
91
  Development:
92
92
  --public Bind dev to all IPv4 interfaces (0.0.0.0) and print network URLs.
@@ -692,13 +692,13 @@ function operationOptions(command,parsed,cwd){
692
692
  return {
693
693
  action:'serve',
694
694
  cwd,
695
- profile:values.profile??'mail',
695
+ profile:values.profile,
696
696
  from:values.from,
697
697
  appId:values.app,
698
698
  origin:values.origin,
699
699
  allowTo:values['allow-to'],
700
- host:values.host??'0.0.0.0',
701
- port:readPort(values.port,4433),
700
+ host:values.host,
701
+ port:readPort(values.port),
702
702
  requestTimeout:readMailRequestTimeout(values['request-timeout']),
703
703
  };
704
704
  }
@@ -721,7 +721,7 @@ function operationOptions(command,parsed,cwd){
721
721
  return {
722
722
  action:'send',
723
723
  cwd,
724
- profile:values.profile??'mail',
724
+ profile:values.profile,
725
725
  from:values.from,
726
726
  reportKey:values['report-key'],
727
727
  reportStdin:true,
@@ -41,38 +41,63 @@ async function readMailSettings(filePath,signal){
41
41
  return settings;
42
42
  }
43
43
 
44
- function mailProfileSettings(settings,location){
44
+ function mailProfileSettings(settings,location,nested=false){
45
+ const source=nested?settings.mail:settings;
46
+ if(source===undefined)return undefined;
47
+ if(!source||!is.object(source)||is.array(source)){
48
+ throw new ArcaneError(ERROR_CODES.usage,`mail in ${location.filePath} must be an object.`);
49
+ }
45
50
  if(location.profile==='mail'){
46
- return settings;
51
+ return source;
47
52
  }
48
- if(settings.MAIL_PROFILES===undefined){
53
+ const profiles=nested?source.profiles:source.MAIL_PROFILES;
54
+ const label=nested?'mail.profiles':'MAIL_PROFILES';
55
+ if(profiles===undefined){
49
56
  return undefined;
50
57
  }
51
- if(!settings.MAIL_PROFILES||!is.object(settings.MAIL_PROFILES)||is.array(settings.MAIL_PROFILES)){
52
- throw new ArcaneError(ERROR_CODES.usage,`MAIL_PROFILES in ${location.filePath} must be an object.`);
58
+ if(!profiles||!is.object(profiles)||is.array(profiles)){
59
+ throw new ArcaneError(ERROR_CODES.usage,`${label} in ${location.filePath} must be an object.`);
53
60
  }
54
- if(!Object.hasOwn(settings.MAIL_PROFILES,location.profile)){
61
+ if(!Object.hasOwn(profiles,location.profile)){
55
62
  return undefined;
56
63
  }
57
- const profileSettings=settings.MAIL_PROFILES[location.profile];
64
+ const profileSettings=profiles[location.profile];
58
65
  if(!profileSettings||!is.object(profileSettings)||is.array(profileSettings)){
59
66
  throw new ArcaneError(
60
67
  ERROR_CODES.usage,
61
- `MAIL_PROFILES[${JSON.stringify(location.profile)}] in ${location.filePath} must be an object.`
68
+ `${label}[${JSON.stringify(location.profile)}] in ${location.filePath} must be an object.`
62
69
  );
63
70
  }
64
71
  return profileSettings;
65
72
  }
66
73
 
74
+ function mailCredentialEntry(settings, location) {
75
+ const nestedSettings = mailProfileSettings(settings, location, true);
76
+ const nestedEntry = {
77
+ profileSettings: nestedSettings,
78
+ key: 'apiKey',
79
+ setting: location.profile === 'mail'
80
+ ? 'mail.apiKey'
81
+ : `mail.profiles[${JSON.stringify(location.profile)}].apiKey`
82
+ };
83
+ if (nestedSettings && Object.hasOwn(nestedSettings, 'apiKey')) return nestedEntry;
84
+ const legacySettings = mailProfileSettings(settings, location);
85
+ if (legacySettings && Object.hasOwn(legacySettings, 'RESEND_API_KEY')) {
86
+ return {profileSettings: legacySettings, key: 'RESEND_API_KEY', setting: location.setting};
87
+ }
88
+ return nestedEntry;
89
+ }
90
+
67
91
  function configuredMailKey(settings,location){
68
- const apiKey=mailProfileSettings(settings,location)?.RESEND_API_KEY;
92
+ const entry=mailCredentialEntry(settings,location);
93
+ const apiKey=entry.profileSettings?.[entry.key];
69
94
  if(apiKey===undefined||apiKey===null||apiKey===''){
70
95
  return null;
71
96
  }
72
97
  if(!is.string(apiKey)){
73
98
  throw new ArcaneError(
74
99
  ERROR_CODES.usage,
75
- `${location.setting} in ${location.filePath} must be a string.`
100
+ `${entry.setting} in ${location.filePath} must be a string.`
76
101
  );
77
102
  }
78
103
  return apiKey;
@@ -88,16 +113,22 @@ export async function setMailCredential(options={}){
88
113
  throw new ArcaneError(ERROR_CODES.usage,'The Resend API key must be a nonempty string.');
89
114
  }
90
115
  const settings=await readMailSettings(location.filePath,options.signal);
91
- const profileSettings=mailProfileSettings(settings,location);
92
- const updatedProfile={...profileSettings,RESEND_API_KEY:options.secret};
93
- const updatedSettings=location.profile==='mail'
94
- ?updatedProfile
95
- :{
96
- ...settings,
97
- MAIL_PROFILES:{...settings.MAIL_PROFILES,[location.profile]:updatedProfile}
98
- };
116
+ const entry=mailCredentialEntry(settings,location);
117
+ if(entry.profileSettings){
118
+ entry.profileSettings[entry.key]=options.secret;
119
+ }else{
120
+ settings.mail??={};
121
+ if(location.profile==='mail'){
122
+ settings.mail.apiKey=options.secret;
123
+ }else{
124
+ settings.mail.profiles={
125
+ ...settings.mail.profiles,
126
+ [location.profile]:{apiKey:options.secret}
127
+ };
128
+ }
129
+ }
99
130
  throwIfAborted(options.signal);
100
- await writeFile(location.filePath,`${JSON.stringify(updatedSettings,null,2)}\n`,{
131
+ await writeFile(location.filePath,`${JSON.stringify(settings,null,2)}\n`,{
101
132
  encoding:'utf8',
102
133
  mode:0o600,
103
134
  signal:options.signal
@@ -111,30 +142,58 @@ export async function readMailCredential(options={}){
111
142
  return configuredMailKey(settings,location);
112
143
  }
113
144
 
114
- export async function readMailServerSettings(options = {}) {
115
- const location = mailCredentialLocation(options);
116
- const settings = await readMailSettings(location.filePath, options.signal);
117
- const serverSettings = (options.readCredential ?? null) === null
118
- ? {apiKey: configuredMailKey(settings, location)}
119
- : {};
145
+ export async function readMailConfiguration(options = {}) {
146
+ const directory = path.resolve(options.cwd ?? options.workspaceRoot ?? process.cwd());
147
+ const configPath = path.join(directory, 'arcane.config.json');
148
+ const envPath = path.join(directory, '.arcane.env.json');
149
+ const readCredentialFromFile = (options.readCredential ?? null) === null;
150
+ const [config, secrets] = await Promise.all(
151
+ [
152
+ readMailSettings(configPath, options.signal),
153
+ options.action !== 'send' || readCredentialFromFile
154
+ ? readMailSettings(envPath, options.signal)
155
+ : {}
156
+ ]
157
+ );
158
+ const mailSettings = config.mail === undefined ? {} : config.mail;
159
+ if (!mailSettings || !is.object(mailSettings) || is.array(mailSettings)) {
160
+ throw new ArcaneError(ERROR_CODES.usage, `mail in ${configPath} must be an object.`);
161
+ }
162
+ const location = mailCredentialLocation(
163
+ {...options, profile: options.profile !== undefined ? options.profile : mailSettings.profile}
164
+ );
165
+ const configuration = {profile: location.profile};
166
+ for (const name of [
167
+ 'host', 'port', 'origins', 'from', 'appId', 'recipientAllowlist',
168
+ 'errorRecipients', 'bodyTimeoutMs', 'providerTimeoutMs', 'retryableDelayMs'
169
+ ]) {
170
+ if (mailSettings[name] !== undefined) configuration[name] = mailSettings[name];
171
+ }
172
+ if (readCredentialFromFile) {
173
+ configuration.apiKey = configuredMailKey(secrets, location);
174
+ }
175
+ // Sending a report does not consume listener certificate configuration.
176
+ if (options.action === 'send') return configuration;
120
177
  const tlsSettings = {
121
178
  MAIL_TLS_CERT_PATH: 'certPath',
122
179
  MAIL_TLS_KEY_PATH: 'keyPath'
123
180
  };
124
181
  for (const [setting, option] of Object.entries(tlsSettings)) {
125
- const value = settings[setting];
182
+ const value = options[option] !== undefined
183
+ ? options[option]
184
+ : mailSettings[option] !== undefined ? mailSettings[option] : secrets[setting];
126
185
  if (value === undefined || value === null || value === '') {
127
186
  continue;
128
187
  }
129
188
  if (!is.string(value)) {
130
189
  throw new ArcaneError(
131
190
  ERROR_CODES.usage,
132
- `${setting} in ${location.filePath} must be a PEM file path string.`
191
+ `mail.${option} (${setting}) must be a PEM file path string.`
133
192
  );
134
193
  }
135
- serverSettings[option] = path.resolve(path.dirname(location.filePath), value);
194
+ configuration[option] = path.resolve(directory, value);
136
195
  }
137
- return serverSettings;
196
+ return configuration;
138
197
  }
139
198
 
140
199
  export async function getMailCredentialStatus(options={}){
@@ -146,17 +205,18 @@ export async function getMailCredentialStatus(options={}){
146
205
  export async function deleteMailCredential(options={}){
147
206
  const location=mailCredentialLocation(options);
148
207
  const settings=await readMailSettings(location.filePath,options.signal);
149
- const profileSettings=mailProfileSettings(settings,location);
150
- if(profileSettings&&Object.hasOwn(profileSettings,'RESEND_API_KEY')){
151
- const {RESEND_API_KEY,...remainingSettings}=profileSettings;
152
- const updatedSettings=location.profile==='mail'
153
- ?remainingSettings
154
- :{
155
- ...settings,
156
- MAIL_PROFILES:{...settings.MAIL_PROFILES,[location.profile]:remainingSettings}
157
- };
208
+ let changed=false;
209
+ for(const nested of [true,false]){
210
+ const profileSettings=mailProfileSettings(settings,location,nested);
211
+ const key=nested?'apiKey':'RESEND_API_KEY';
212
+ if(profileSettings&&Object.hasOwn(profileSettings,key)){
213
+ delete profileSettings[key];
214
+ changed=true;
215
+ }
216
+ }
217
+ if(changed){
158
218
  throwIfAborted(options.signal);
159
- await writeFile(location.filePath,`${JSON.stringify(updatedSettings,null,2)}\n`,{
219
+ await writeFile(location.filePath,`${JSON.stringify(settings,null,2)}\n`,{
160
220
  encoding:'utf8',
161
221
  signal:options.signal
162
222
  });
package/src/mail.mjs CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  getMailCredentialStatus,
6
6
  mailCredentialLocation,
7
7
  readMailCredential,
8
- readMailServerSettings,
8
+ readMailConfiguration,
9
9
  setMailCredential
10
10
  } from './mail-credentials.mjs';
11
11
  import {sendResendMail,startResendMailServer} from './mail-server.mjs';
@@ -62,6 +62,12 @@ function mailCredentialOptions(options){
62
62
  };
63
63
  }
64
64
 
65
+ function configuredMailOption(options, settings, name, alias = name) {
66
+ if (options[alias] !== undefined) return options[alias];
67
+ if (options[name] !== undefined) return options[name];
68
+ return settings[name];
69
+ }
70
+
65
71
  async function setMailCredentialFromInput(options){
66
72
  const readSecret=resolveMailCommandDependency(options,'readSecret',null);
67
73
  const store=resolveMailCommandDependency(options,'setCredential',setMailCredential);
@@ -94,20 +100,23 @@ async function sendMailFromReport(options){
94
100
  throwIfAborted(options.signal);
95
101
  const report=await readReport();
96
102
  throwIfAborted(options.signal);
97
- let apiKey=await readMailProviderKey(options);
103
+ const readConfiguration=resolveMailCommandDependency(options,'readServerSettings',readMailConfiguration);
104
+ const mailSettings=await readConfiguration(options);
105
+ throwIfAborted(options.signal);
106
+ let apiKey=await readMailProviderKey(options,mailSettings);
98
107
  try{
99
108
  throwIfAborted(options.signal);
100
109
  const result=await send({
101
110
  apiKey,
102
111
  appId:'arcane-cli',
103
112
  fetchImpl:options.fetchImpl,
104
- from:options.from,
113
+ from:configuredMailOption(options,mailSettings,'from'),
105
114
  onEvent:options.onEvent,
106
- providerTimeoutMs:options.requestTimeout,
115
+ providerTimeoutMs:configuredMailOption(options,mailSettings,'providerTimeoutMs','requestTimeout'),
107
116
  report,
108
117
  reportKey:options.reportKey,
109
118
  requestIdFactory:options.requestIdFactory,
110
- retryableDelayMs:options.retryableDelayMs,
119
+ retryableDelayMs:configuredMailOption(options,mailSettings,'retryableDelayMs'),
111
120
  signal:options.signal
112
121
  });
113
122
  if(result?.classification==='accepted'&&result.status==='accepted'){
@@ -126,16 +135,21 @@ async function sendMailFromReport(options){
126
135
  }
127
136
  }
128
137
 
129
- async function readMailProviderKey(options, serverSettings){
130
- const credentialOptions=mailCredentialOptions(options);
131
- const apiKey = serverSettings !== undefined && (options.readCredential ?? null) === null
132
- ? serverSettings.apiKey
138
+ async function readMailProviderKey(options, mailSettings){
139
+ const credentialOptions=mailCredentialOptions(
140
+ {...options,profile:options.profile??mailSettings?.profile}
141
+ );
142
+ const apiKey = mailSettings !== undefined && (options.readCredential ?? null) === null
143
+ ? mailSettings.apiKey
133
144
  : await resolveMailCommandDependency(options, 'readCredential', readMailCredential)(credentialOptions);
134
145
  if(apiKey===null){
135
146
  const location=mailCredentialLocation(credentialOptions);
147
+ const nestedSetting = location.profile === 'mail'
148
+ ? 'mail.apiKey'
149
+ : `mail.profiles[${JSON.stringify(location.profile)}].apiKey`;
136
150
  throw new ArcaneError(
137
151
  ERROR_CODES.prerequisiteMissing,
138
- `Missing ${location.setting} in ${location.filePath}.`
152
+ `Missing ${location.setting} or ${nestedSetting} in ${location.filePath}.`
139
153
  );
140
154
  }
141
155
  if(!is.string(apiKey)||!apiKey){
@@ -149,11 +163,9 @@ async function readMailProviderKey(options, serverSettings){
149
163
 
150
164
  async function serveMailGateway(options){
151
165
  const startServer=resolveMailCommandDependency(options,'startServer',startResendMailServer);
152
- const readServerSettings = resolveMailCommandDependency(options, 'readServerSettings', readMailServerSettings);
166
+ const readServerSettings = resolveMailCommandDependency(options, 'readServerSettings', readMailConfiguration);
153
167
  throwIfAborted(options.signal);
154
- const serverSettings = await readServerSettings(
155
- {...mailCredentialOptions(options), readCredential: options.readCredential}
156
- );
168
+ const serverSettings = await readServerSettings(options);
157
169
  throwIfAborted(options.signal);
158
170
  let apiKey=await readMailProviderKey(options, serverSettings);
159
171
  try{
@@ -165,32 +177,36 @@ async function serveMailGateway(options){
165
177
  const location = mailCredentialLocation(options);
166
178
  throw new ArcaneError(
167
179
  ERROR_CODES.prerequisiteMissing,
168
- `Missing ${missingSettings.join(', ')} in ${location.filePath}. Mail HTTPS requires a certificate and private key.`
180
+ `Missing ${missingSettings.join(', ')} in ${location.filePath}, or the corresponding mail.certPath/mail.keyPath in arcane.config.json. Mail HTTPS requires a certificate and private key.`
169
181
  );
170
182
  }
171
- const recipientAllowlist=mailRecipientOptions(options.allowTo,'allowTo');
172
- const errorRecipients=options.errorTo===undefined
183
+ const recipientAllowlist=mailRecipientOptions(
184
+ configuredMailOption(options,serverSettings,'recipientAllowlist','allowTo'),'recipientAllowlist'
185
+ );
186
+ const errorTo=configuredMailOption(options,serverSettings,'errorRecipients','errorTo');
187
+ const errorRecipients=errorTo===undefined
173
188
  ? recipientAllowlist
174
- : mailRecipientOptions(options.errorTo,'errorTo');
189
+ : mailRecipientOptions(errorTo,'errorRecipients');
190
+ const origins=configuredMailOption(options,serverSettings,'origins','origin');
175
191
  return await startServer({
176
192
  apiKey,
177
- appId:options.appId,
178
- allowedOrigins:options.origin===undefined
193
+ appId:configuredMailOption(options,serverSettings,'appId'),
194
+ allowedOrigins:origins===undefined
179
195
  ?[]
180
- :is.array(options.origin)?[...options.origin]:[options.origin],
181
- bodyTimeoutMs:options.bodyTimeoutMs,
196
+ :is.array(origins)?[...origins]:[origins],
197
+ bodyTimeoutMs:configuredMailOption(options,serverSettings,'bodyTimeoutMs'),
182
198
  certPath:serverSettings.certPath,
183
199
  errorRecipients,
184
200
  fetchImpl:options.fetchImpl,
185
- from:options.from,
186
- host:options.host??'0.0.0.0',
201
+ from:configuredMailOption(options,serverSettings,'from'),
202
+ host:configuredMailOption(options,serverSettings,'host')??'0.0.0.0',
187
203
  keyPath:serverSettings.keyPath,
188
204
  onEvent:options.onEvent,
189
- port:options.port,
190
- providerTimeoutMs:options.requestTimeout,
205
+ port:configuredMailOption(options,serverSettings,'port'),
206
+ providerTimeoutMs:configuredMailOption(options,serverSettings,'providerTimeoutMs','requestTimeout'),
191
207
  recipientAllowlist,
192
208
  requestIdFactory:options.requestIdFactory,
193
- retryableDelayMs:options.retryableDelayMs,
209
+ retryableDelayMs:configuredMailOption(options,serverSettings,'retryableDelayMs'),
194
210
  signal:options.signal,
195
211
  verifySubscription:options.verifySubscription
196
212
  });