@serfab/cadre-core 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +2 -2
  2. package/dist/cadre-node.d.ts +101 -61
  3. package/dist/cadre-node.js +165 -124
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-cohort.d.ts +15 -15
  6. package/dist/control-cohort.js +14 -14
  7. package/dist/control-cohort.js.map +1 -1
  8. package/dist/control-database.d.ts +51 -34
  9. package/dist/control-database.js +72 -56
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-formation-recorder.d.ts +2 -2
  12. package/dist/control-formation-recorder.js +2 -2
  13. package/dist/control-schema.d.ts +1 -1
  14. package/dist/control-schema.js +57 -33
  15. package/dist/control-schema.js.map +1 -1
  16. package/dist/device-token.d.ts +1 -1
  17. package/dist/device-token.js +1 -1
  18. package/dist/{authority-key.d.ts → ed25519-key.d.ts} +11 -10
  19. package/dist/{authority-key.js → ed25519-key.js} +9 -10
  20. package/dist/ed25519-key.js.map +1 -0
  21. package/dist/index.d.ts +3 -3
  22. package/dist/index.js +8 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/key-store.d.ts +1 -1
  25. package/dist/key-store.js +1 -1
  26. package/dist/peer-authorization.d.ts +28 -6
  27. package/dist/peer-authorization.js +33 -7
  28. package/dist/peer-authorization.js.map +1 -1
  29. package/dist/peer-record.d.ts +3 -3
  30. package/dist/peer-record.js +3 -3
  31. package/dist/push-node.d.ts +38 -0
  32. package/dist/push-node.js +48 -0
  33. package/dist/push-node.js.map +1 -0
  34. package/dist/push-notifier-apns.d.ts +10 -2
  35. package/dist/push-notifier-apns.js +14 -10
  36. package/dist/push-notifier-apns.js.map +1 -1
  37. package/dist/push-notifier-fcm.d.ts +7 -1
  38. package/dist/push-notifier-fcm.js +9 -10
  39. package/dist/push-notifier-fcm.js.map +1 -1
  40. package/dist/push-notifier.d.ts +16 -35
  41. package/dist/push-notifier.js +16 -44
  42. package/dist/push-notifier.js.map +1 -1
  43. package/dist/seed-bootstrap.d.ts +70 -51
  44. package/dist/seed-bootstrap.js +168 -106
  45. package/dist/seed-bootstrap.js.map +1 -1
  46. package/dist/seed-trust-policy.d.ts +11 -11
  47. package/dist/seed-trust-policy.js +11 -11
  48. package/dist/seed-trust-policy.js.map +1 -1
  49. package/dist/strand-database.d.ts +5 -5
  50. package/dist/strand-database.js +4 -4
  51. package/dist/strand-database.js.map +1 -1
  52. package/dist/strand-formation-protocol.d.ts +1 -1
  53. package/dist/strand-formation-protocol.js +1 -1
  54. package/dist/strand-instance-manager.d.ts +1 -1
  55. package/dist/strand-instance-manager.js +1 -1
  56. package/dist/strand-instance-manager.js.map +1 -1
  57. package/dist/strand-member-key.d.ts +6 -6
  58. package/dist/strand-member-key.js +6 -6
  59. package/dist/strand-member-key.js.map +1 -1
  60. package/dist/strand-member-registry.d.ts +7 -7
  61. package/dist/strand-member-registry.js +5 -5
  62. package/dist/strand-member-registry.js.map +1 -1
  63. package/dist/strand-membership-writer.d.ts +84 -84
  64. package/dist/strand-membership-writer.js +90 -90
  65. package/dist/strand-membership-writer.js.map +1 -1
  66. package/dist/types.d.ts +52 -32
  67. package/dist/types.js.map +1 -1
  68. package/package.json +7 -3
  69. package/src/cadre-node.ts +170 -126
  70. package/src/control-cohort.ts +17 -17
  71. package/src/control-database.ts +76 -59
  72. package/src/control-formation-recorder.ts +2 -2
  73. package/src/control-schema.ts +57 -33
  74. package/src/device-token.ts +1 -1
  75. package/src/{authority-key.ts → ed25519-key.ts} +12 -11
  76. package/src/index.ts +21 -19
  77. package/src/key-store.ts +1 -1
  78. package/src/peer-authorization.ts +35 -7
  79. package/src/peer-record.ts +3 -3
  80. package/src/push-node.ts +64 -0
  81. package/src/push-notifier-apns.ts +16 -12
  82. package/src/push-notifier-fcm.ts +9 -10
  83. package/src/push-notifier.ts +16 -57
  84. package/src/seed-bootstrap.ts +179 -115
  85. package/src/seed-trust-policy.ts +18 -18
  86. package/src/strand-database.ts +6 -6
  87. package/src/strand-formation-protocol.ts +1 -1
  88. package/src/strand-instance-manager.ts +2 -2
  89. package/src/strand-member-key.ts +7 -7
  90. package/src/strand-member-registry.ts +10 -10
  91. package/src/strand-membership-writer.ts +127 -127
  92. package/src/types.ts +52 -32
  93. package/dist/authority-key.js.map +0 -1
@@ -43,7 +43,7 @@ export function deviceTokenSignedPayload(record: Omit<DeviceTokenRecord, 'sig'>)
43
43
  * Returns a fully-populated {@link DeviceTokenRecord}.
44
44
  *
45
45
  * @param fields - the record fields to sign
46
- * @param privateKeyB64 - base64url ed25519 seed (see `authorityKeyFromLibp2p`)
46
+ * @param privateKeyB64 - base64url ed25519 seed (see `ed25519KeyPairFromLibp2p`)
47
47
  */
48
48
  export function signDeviceTokenRecord(
49
49
  fields: Omit<DeviceTokenRecord, 'sig'>,
@@ -3,10 +3,12 @@ import { getPublicKey } from '@optimystic/quereus-plugin-crypto';
3
3
  import type { PrivateKey } from '@libp2p/interface';
4
4
 
5
5
  /**
6
- * A household authority keypair expressed in the base64url Ed25519 form that
6
+ * An Ed25519 keypair expressed in the base64url form that
7
7
  * `@optimystic/quereus-plugin-crypto` (`sign`/`verify`/`getPublicKey`) consumes.
8
+ * Generic bridge type — reused for owner keys, member keys, and any other
9
+ * seed→public keypair the control/strand databases sign with.
8
10
  */
9
- export interface AuthorityKeyPair {
11
+ export interface Ed25519KeyPair {
10
12
  /** 32-byte Ed25519 seed, base64url-encoded — the crypto-plugin private key. */
11
13
  privateKeyB64: string;
12
14
  /** 32-byte Ed25519 public key, base64url-encoded. */
@@ -15,23 +17,23 @@ export interface AuthorityKeyPair {
15
17
 
16
18
  /**
17
19
  * Bridge a libp2p Ed25519 private key into the base64url keypair used by the
18
- * control-database authority constraints.
20
+ * control- and strand-database signing constraints.
19
21
  *
20
22
  * libp2p stores an Ed25519 private key as 64 raw bytes: the first 32 are the
21
23
  * seed (the actual scalar source), the last 32 are the public key — see
22
24
  * `@libp2p/crypto`'s `Ed25519PrivateKey`. `@optimystic/quereus-plugin-crypto`
23
25
  * (via `@noble/curves`) treats the 32-byte seed *as* the private key and
24
26
  * derives the public key from it with standard Ed25519. The two derivations
25
- * agree, so the node's peer identity and its authority key are one keypair:
27
+ * agree, so the node's peer identity and its signing key are one keypair:
26
28
  * `getPublicKey(privateKeyB64)` === `publicKeyB64`.
27
29
  *
28
30
  * @param privateKey - The node's libp2p Ed25519 private key.
29
- * @returns The base64url seed/public-key pair for authority operations.
31
+ * @returns The base64url seed/public-key pair for signing operations.
30
32
  * @throws If the key is not Ed25519 or the raw bytes aren't the expected length.
31
33
  */
32
- export function authorityKeyFromLibp2p(privateKey: PrivateKey): AuthorityKeyPair {
34
+ export function ed25519KeyPairFromLibp2p(privateKey: PrivateKey): Ed25519KeyPair {
33
35
  if (privateKey.type !== 'Ed25519') {
34
- throw new Error(`authorityKeyFromLibp2p requires an Ed25519 key, got ${privateKey.type}`);
36
+ throw new Error(`ed25519KeyPairFromLibp2p requires an Ed25519 key, got ${privateKey.type}`);
35
37
  }
36
38
 
37
39
  const raw = privateKey.raw;
@@ -55,14 +57,13 @@ export function authorityKeyFromLibp2p(privateKey: PrivateKey): AuthorityKeyPair
55
57
  * Derive the base64url Ed25519 public key from a base64url 32-byte private seed
56
58
  * — the same derivation the seed-bootstrap signer uses internally
57
59
  * (`SeedBootstrapService` constructor). Use this to enroll a standalone
58
- * (non-libp2p) authority key into the control DB via
59
- * `ControlDatabase.ensureAuthorityKey` before minting an invite, when the
60
- * authority key is *not* the node's peer identity (so `authorityKeyFromLibp2p`,
60
+ * (non-libp2p) key into the control DB before minting an invite, when the
61
+ * key is *not* the node's peer identity (so `ed25519KeyPairFromLibp2p`,
61
62
  * which needs a libp2p key object, does not apply).
62
63
  *
63
64
  * @param privateKeyB64 - The base64url-encoded 32-byte Ed25519 seed.
64
65
  * @returns The base64url-encoded Ed25519 public key.
65
66
  */
66
- export function authorityPublicKeyFromPrivate(privateKeyB64: string): string {
67
+ export function ed25519PublicKeyFromPrivate(privateKeyB64: string): string {
67
68
  return getPublicKey(privateKeyB64, 'ed25519', 'base64url', 'base64url') as string;
68
69
  }
package/src/index.ts CHANGED
@@ -10,10 +10,10 @@ export { CadreNode } from './cadre-node.js';
10
10
  // Control database
11
11
  export { ControlDatabase, buildAuthorizationMessage, type ControlDatabaseConfig, type ControlTable } from './control-database.js';
12
12
 
13
- // Authority key bridge (libp2p Ed25519 -> base64url authority keypair)
14
- export { authorityKeyFromLibp2p, authorityPublicKeyFromPrivate, type AuthorityKeyPair } from './authority-key.js';
13
+ // Ed25519 key bridge (libp2p Ed25519 -> base64url keypair)
14
+ export { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, type Ed25519KeyPair } from './ed25519-key.js';
15
15
 
16
- // Pluggable key store (backend-agnostic identity/authority key material seam).
16
+ // Pluggable key store (backend-agnostic identity/owner key material seam).
17
17
  // Dependency-free: the interface, error, default slot id, and in-memory backend
18
18
  // are safe in every (RN/browser/Node) entry graph. The Node FileKeyStore is a
19
19
  // separate subpath module ('@serfab/cadre-core/key-store-file') so its node:fs
@@ -81,11 +81,11 @@ export {
81
81
  export { ControlFormationUsageRecorder } from './control-formation-recorder.js';
82
82
 
83
83
  // Closed-strand member key generation (ed25519 protobuf, base64) + the
84
- // protobuf->base64url bridge that derives the founding Member/Authority key.
84
+ // protobuf->base64url bridge that derives the founding Member/Manager key.
85
85
  export { generateStrandMemberKey, strandMemberKeyPair } from './strand-member-key.js';
86
86
 
87
87
  // Strand membership writer (founder bootstrap, invite issuance/consumption,
88
- // authority-admit, member-peer registration, authority rotation, + shared signing
88
+ // manager-admit, member-peer registration, manager rotation, + shared signing
89
89
  // primitives reused across the flows).
90
90
  export {
91
91
  signStrandPayload,
@@ -93,20 +93,20 @@ export {
93
93
  bootstrapFounderMembership,
94
94
  issueInvite,
95
95
  consumeInvite,
96
- addMemberByAuthority,
96
+ addMemberByManager,
97
97
  registerMemberPeer,
98
- addAuthority,
99
- removeAuthority,
98
+ addManager,
99
+ removeManager,
100
100
  STRAND_ENGINE,
101
101
  STRAND_ENGINE_VERSION,
102
102
  type FounderBootstrapParams,
103
103
  type IssueInviteParams,
104
104
  type IssuedInvite,
105
105
  type ConsumeInviteParams,
106
- type AddMemberByAuthorityParams,
106
+ type AddMemberByManagerParams,
107
107
  type RegisterMemberPeerParams,
108
- type AddAuthorityParams,
109
- type RemoveAuthorityParams
108
+ type AddManagerParams,
109
+ type RemoveManagerParams
110
110
  } from './strand-membership-writer.js';
111
111
 
112
112
  // Engine-canonical datetime helper (shared by control + strand signed-write flows
@@ -131,9 +131,11 @@ export {
131
131
  type SeedEventCallbacks
132
132
  } from './seed-bootstrap.js';
133
133
 
134
- // Peer Authorization (shared authority-signature digest + offline verifier)
134
+ // Peer Authorization (shared owner-signature digest + offline verifier)
135
135
  export {
136
136
  peerAuthorizationDigest,
137
+ cadrePeerVoucherDigest,
138
+ cadrePeerRemoveDigest,
137
139
  verifyPeerAuthorization
138
140
  } from './peer-authorization.js';
139
141
 
@@ -181,13 +183,13 @@ export {
181
183
  // Strand-wake payload contract (canonical; shared by the server sender + RN receiver)
182
184
  export { STRAND_WAKE_TYPE, type StrandWakePayload } from './strand-wake-payload.js';
183
185
 
184
- // Platform push delivery — server-only PushNotifier (FCM HTTP v1 / APNs HTTP/2).
185
- // TYPES ONLY here: the implementations import node:http2/node:crypto and are
186
- // constructed solely by `CadreNode.start` (via a guarded dynamic import) when
187
- // push credentials are configured. A type-only re-export is erased at emit, so the
188
- // cross-platform (RN/browser) entry graph never pulls these modules in.
189
- // `createPushNotifier` is imported directly from './push-notifier.js' only by that
190
- // dynamic import, never re-exported as a runtime value.
186
+ // Platform push delivery — the cross-platform `PushNotifier` *contract* only.
187
+ // The FCM/APNs implementations (node:http2/node:crypto) and the
188
+ // `createPushNotifier` router that builds them live behind the Node-only subpath
189
+ // `@serfab/cadre-core/push-node` (`push-node.ts`), never in this graph. A Node
190
+ // host constructs a notifier from that subpath and injects the instance into
191
+ // `CadreNodeConfig.push.notifier`. These re-exports are type-only (erased at
192
+ // emit), so the RN/browser entry graph never pulls the Node modules in.
191
193
  export type { PushNotifier, PushMessage, PushSendResult } from './push-notifier.js';
192
194
 
193
195
  // Push-credential validation + log redaction. Dependency-free (type-only imports),
package/src/key-store.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Backend-agnostic store for raw private key material.
3
3
  *
4
4
  * Mobile cadre nodes hold sensitive key material at rest: the libp2p
5
- * peer/node identity key and (in the single-key reference model) the authority
5
+ * peer/node identity key and (in the single-key reference model) the owner
6
6
  * signing key derived from it. This module defines the seam those keys flow
7
7
  * through so a platform-secure backend (iOS Keychain / Android Keystore) can be
8
8
  * plugged in without `@serfab/cadre-core` taking any platform dependency.
@@ -4,12 +4,12 @@ import { digest, verify } from '@optimystic/quereus-plugin-crypto';
4
4
  const log = debug('sereus:cadre:peer-authorization');
5
5
 
6
6
  /**
7
- * Canonical digest an authority signs to authorize a peer.
7
+ * Canonical digest an owner signs to authorize a peer.
8
8
  *
9
9
  * This is the exact byte construction {@link SeedBootstrapService.authorizePeer}
10
10
  * signs over when it inserts an authorized `CadrePeer` row:
11
11
  * `digest([peerId], 'sha256', 'base64url')`. Factored into one place so the
12
- * producer (authority signing) and the verifier (the offline `cadre enroll
12
+ * producer (owner signing) and the verifier (the offline `cadre enroll
13
13
  * register` check) can never drift apart — change the digest here and both move
14
14
  * together. The SQL mirror is the bare single-field `digest(coalesce(new.PeerId, old.PeerId))`.
15
15
  */
@@ -18,13 +18,41 @@ export function peerAuthorizationDigest(peerId: string): string {
18
18
  }
19
19
 
20
20
  /**
21
- * Verify that `signature` is a valid authority ed25519 signature over `peerId`'s
22
- * authorization digest, using `authorityPublicKey` (base64url).
21
+ * Canonical digest an owner signs to VOUCH a `CadrePeer` membership row (insert
22
+ * and the owner re-touch update). Binds the peer id to the row's single-use
23
+ * `StampId` nonce, so a captured signed insert cannot be replayed (the `StampId` is
24
+ * unique) and — because {@link cadrePeerRemoveDigest} scopes a DIFFERENT payload — the
25
+ * stored voucher (`VouchSig`) cannot be replayed to authorize a delete.
26
+ *
27
+ * The SQL mirror is `digest(new.PeerId, new.StampId)` (two TEXT fields), matching the
28
+ * canonical multi-field framing in control-database.ts:buildAuthorizationMessage.
29
+ */
30
+ export function cadrePeerVoucherDigest(peerId: string, stampId: string): string {
31
+ return digest([peerId, stampId], 'sha256', 'base64url') as string;
32
+ }
33
+
34
+ /**
35
+ * Canonical digest an owner signs to REMOVE a `CadrePeer` row. Deliberately a
36
+ * distinct payload from {@link cadrePeerVoucherDigest} (adds the `'remove'` action
37
+ * tag) so the row's stored voucher — a signature over the voucher digest — can never
38
+ * satisfy this delete check. The signature is supplied in write context and never
39
+ * stored, so no reader can replay it; binding the live row's `StampId` also invalidates
40
+ * any captured remove after a delete+reinsert (the nonce rotates).
41
+ *
42
+ * SQL mirror: `digest(old.PeerId, old.StampId, 'remove')`.
43
+ */
44
+ export function cadrePeerRemoveDigest(peerId: string, stampId: string): string {
45
+ return digest([peerId, stampId, 'remove'], 'sha256', 'base64url') as string;
46
+ }
47
+
48
+ /**
49
+ * Verify that `signature` is a valid owner ed25519 signature over `peerId`'s
50
+ * authorization digest, using `ownerPublicKey` (base64url).
23
51
  *
24
52
  * This is the mirror of the signing done in
25
53
  * {@link SeedBootstrapService.authorizePeer}: it checks the signature against
26
54
  * {@link peerAuthorizationDigest}. A `true` result means the holder of the
27
- * authority private key vouched for this peer ID — it does NOT mean the peer is
55
+ * owner private key vouched for this peer ID — it does NOT mean the peer is
28
56
  * registered anywhere.
29
57
  *
30
58
  * Returns a boolean and never throws: malformed base64url, a bad/garbage key, or
@@ -33,14 +61,14 @@ export function peerAuthorizationDigest(peerId: string): string {
33
61
  */
34
62
  export function verifyPeerAuthorization(
35
63
  peerId: string,
36
- authorityPublicKey: string,
64
+ ownerPublicKey: string,
37
65
  signature: string
38
66
  ): boolean {
39
67
  try {
40
68
  return verify(
41
69
  peerAuthorizationDigest(peerId),
42
70
  signature,
43
- authorityPublicKey,
71
+ ownerPublicKey,
44
72
  'ed25519',
45
73
  'base64url',
46
74
  'base64url',
@@ -61,7 +61,7 @@ export function peerRecordSignedPayload(peerId: string, multiaddr: string, updat
61
61
  * preserved and is the order signed over).
62
62
  *
63
63
  * @param fields - the record fields to sign (signaling addr first by convention)
64
- * @param privateKeyB64 - base64url ed25519 seed (see `authorityKeyFromLibp2p`)
64
+ * @param privateKeyB64 - base64url ed25519 seed (see `ed25519KeyPairFromLibp2p`)
65
65
  */
66
66
  export function signPeerRecord(
67
67
  fields: { peerId: string; publicKey: string; addrs: string[]; updatedAt: number },
@@ -132,8 +132,8 @@ export function orderSignalingFirst(addrs: string[]): string[] {
132
132
  /**
133
133
  * Default resolve trust gate: trust any peer that has a `CadrePeer` row.
134
134
  *
135
- * A row only exists because an authority signed its `AuthorizedInsert`, so row
136
- * presence already means "authority-vouched member". This is the seam where a
135
+ * A row only exists because an owner signed its `AuthorizedInsert`, so row
136
+ * presence already means "owner-vouched member". This is the seam where a
137
137
  * stricter policy (trust circle, pinned keys) plugs in — see
138
138
  * {@link PeerResolveTrustPolicy}.
139
139
  */
@@ -0,0 +1,64 @@
1
+ /**
2
+ * push-node.ts — Node-only subpath entry for platform push **construction**.
3
+ *
4
+ * Import from the dedicated subpath, never the package root:
5
+ *
6
+ * ```ts
7
+ * import { createPushNotifier } from '@serfab/cadre-core/push-node';
8
+ * ```
9
+ *
10
+ * The FCM/APNs implementations reach for `node:crypto` / `node:http2`, so they
11
+ * are quarantined here — unreachable from the cross-platform `./index.js` graph
12
+ * (same isolation pattern as `./key-store-file`). A React Native / browser entry
13
+ * never resolves this path, so the Node builtins never reach a bundler that
14
+ * cannot satisfy them. The core references only the {@link PushNotifier}
15
+ * *interface* (`push-notifier.ts`) and a Node host injects the instance built
16
+ * here into `CadreNodeConfig.push.notifier`.
17
+ */
18
+
19
+ import type { PushCredentials, PushPlatform } from './types.js';
20
+ import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
21
+ import { createFcmPushNotifier, type FcmPushDeps } from './push-notifier-fcm.js';
22
+ import { createApnsPushNotifier, type ApnsPushDeps } from './push-notifier-apns.js';
23
+
24
+ /**
25
+ * Per-platform transport/clock/log seams, injected by tests. Defaults are the
26
+ * real implementations (global `fetch` for FCM, a `node:http2` session for APNs).
27
+ */
28
+ export interface PushNotifierDeps {
29
+ fcm?: FcmPushDeps;
30
+ apns?: ApnsPushDeps;
31
+ }
32
+
33
+ /**
34
+ * Build a {@link PushNotifier} router over the configured credentials. Only the
35
+ * platforms whose credentials are present get a backing implementation; a `send`
36
+ * for an unconfigured platform returns a best-effort `no <platform> credentials`
37
+ * failure rather than throwing.
38
+ */
39
+ export function createPushNotifier(creds: PushCredentials, deps: PushNotifierDeps = {}): PushNotifier {
40
+ const fcm = creds.fcm ? createFcmPushNotifier(creds.fcm, deps.fcm) : undefined;
41
+ const apns = creds.apns ? createApnsPushNotifier(creds.apns, deps.apns) : undefined;
42
+
43
+ async function send(msg: PushMessage): Promise<PushSendResult> {
44
+ if (msg.platform === 'fcm') return fcm ? fcm.send(msg) : noCreds('fcm');
45
+ if (msg.platform === 'apns') return apns ? apns.send(msg) : noCreds('apns');
46
+ return { ok: false, unregistered: false, error: `unknown platform ${String(msg.platform)}` };
47
+ }
48
+
49
+ async function close(): Promise<void> {
50
+ await Promise.all([fcm?.close(), apns?.close()]);
51
+ }
52
+
53
+ return { send, close };
54
+ }
55
+
56
+ /** Best-effort no-op result for a platform with no configured credentials. */
57
+ function noCreds(platform: PushPlatform): PushSendResult {
58
+ return { ok: false, unregistered: false, error: `no ${platform} credentials` };
59
+ }
60
+
61
+ // Per-platform builders + their injected-dep types, for a host that wants a
62
+ // single-platform notifier or to inject a fake transport directly.
63
+ export { createFcmPushNotifier, createApnsPushNotifier };
64
+ export type { FcmPushDeps, ApnsPushDeps };
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * push-notifier-apns.ts — APNs HTTP/2 strand-wake delivery.
3
3
  *
4
- * Server-only: signs a provider JWT (ES256, JOSE raw r‖s via `node:crypto`) and
5
- * sends a background data push over an HTTP/2 session to Apple's gateway.
4
+ * Node-only: signs a provider JWT (ES256, JOSE raw r‖s via `node:crypto`) and
5
+ * sends a background data push over an HTTP/2 session to Apple's gateway. APNs
6
+ * mandates HTTP/2, so `node:http2` is unavoidable here — one reason the push
7
+ * path is injected rather than rebuilt on WebCrypto/`fetch`.
8
+ *
9
+ * Reachable only through the `@serfab/cadre-core/push-node` subpath — never from
10
+ * the cross-platform `./index.js` graph — so the `node:crypto` / `node:http2`
11
+ * imports below are safe: no RN/browser bundler ever resolves this module. (Plain
12
+ * named imports; the old namespace-import-to-only-warn dance is obsolete now that
13
+ * the module is unreachable from a browser build by construction.)
6
14
  *
7
15
  * The HTTP/2 call is behind an injected `Http2Requester` seam (bundled with a
8
16
  * `close` into an {@link ApnsTransport}) so unit tests assert the request shape
@@ -19,12 +27,8 @@
19
27
  * token prefix.
20
28
  */
21
29
 
22
- // Namespace import (not `import { sign }`): a *named* import of the
23
- // browser-externalized `node:crypto` hard-fails the web build, whereas a
24
- // namespace import only warns — same reasoning as the `node:http2` import below
25
- // and the matching note in push-notifier-fcm.ts. Server-only; never run in browser.
26
- import * as nodeCrypto from 'node:crypto';
27
- import * as http2 from 'node:http2';
30
+ import { sign } from 'node:crypto';
31
+ import { connect, type ClientHttp2Session } from 'node:http2';
28
32
  import debug from 'debug';
29
33
  import type { ApnsCredentials } from './types.js';
30
34
  import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
@@ -171,7 +175,7 @@ export function createApnsPushNotifier(creds: ApnsCredentials, deps: ApnsPushDep
171
175
  const header = { alg: 'ES256', kid: creds.keyId };
172
176
  const claims = { iss: creds.teamId, iat };
173
177
  const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
174
- const sig = nodeCrypto.sign('SHA256', Buffer.from(signingInput), {
178
+ const sig = sign('SHA256', Buffer.from(signingInput), {
175
179
  key: creds.privateKey,
176
180
  dsaEncoding: 'ieee-p1363',
177
181
  }).toString('base64url');
@@ -195,11 +199,11 @@ export function createApnsPushNotifier(creds: ApnsCredentials, deps: ApnsPushDep
195
199
  * network) — tests inject a fake transport; the real path is validated out-of-band.
196
200
  */
197
201
  function createHttp2Transport(host: string): ApnsTransport {
198
- let session: http2.ClientHttp2Session | null = null;
202
+ let session: ClientHttp2Session | null = null;
199
203
 
200
- function ensureSession(): http2.ClientHttp2Session {
204
+ function ensureSession(): ClientHttp2Session {
201
205
  if (session && !session.closed && !session.destroyed) return session;
202
- const s = http2.connect(host);
206
+ const s = connect(host);
203
207
  // Drop a dead session so ensureSession recreates it on the next request.
204
208
  s.on('error', () => { if (session === s) session = null; });
205
209
  s.on('goaway', () => { s.destroy(); });
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * push-notifier-fcm.ts — FCM HTTP v1 strand-wake delivery.
3
3
  *
4
- * Server-only: mints a Google OAuth2 access token (RS256 JWT bearer grant signed
4
+ * Node-only: mints a Google OAuth2 access token (RS256 JWT bearer grant signed
5
5
  * with `node:crypto`) and POSTs a data message to the FCM v1 endpoint. The legacy
6
6
  * server-key `fcm.googleapis.com/fcm/send` API is deprecated, so we use HTTP v1.
7
7
  *
8
+ * Reachable only through the `@serfab/cadre-core/push-node` subpath — never from
9
+ * the cross-platform `./index.js` graph — so the `node:crypto` import below is
10
+ * safe: no RN/browser bundler ever resolves this module. (Plain named import;
11
+ * the old namespace-import-to-only-warn dance is obsolete now that the module is
12
+ * unreachable from a browser build by construction.)
13
+ *
8
14
  * The network call is behind an injected `fetch`-like seam so unit tests assert
9
15
  * the request shape and map every documented response code with no real network
10
16
  * and no credentials. Failures are returned as {@link PushSendResult} values.
@@ -14,14 +20,7 @@
14
20
  * code and a redacted token prefix.
15
21
  */
16
22
 
17
- // Namespace import (not `import { sign }`): this module is server-only and is
18
- // reached only via the guarded dynamic import in CadreNode, but bundlers still
19
- // analyse that lazy chunk. A *named* import of `node:crypto` hard-fails the
20
- // browser build ("sign is not exported by __vite-browser-external"), whereas a
21
- // namespace import of the deliberately-unaliased external only warns (matching
22
- // the `node:http2` namespace import in push-notifier-apns.ts). The browser
23
- // never executes this code (config.push is node-only), so the warning is benign.
24
- import * as nodeCrypto from 'node:crypto';
23
+ import { sign } from 'node:crypto';
25
24
  import debug from 'debug';
26
25
  import type { FcmCredentials } from './types.js';
27
26
  import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
@@ -166,7 +165,7 @@ export function createFcmPushNotifier(creds: FcmCredentials, deps: FcmPushDeps =
166
165
  exp: iat + 3600,
167
166
  };
168
167
  const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
169
- const sig = nodeCrypto.sign('RSA-SHA256', Buffer.from(signingInput), creds.privateKey).toString('base64url');
168
+ const sig = sign('RSA-SHA256', Buffer.from(signingInput), creds.privateKey).toString('base64url');
170
169
  return `${signingInput}.${sig}`;
171
170
  }
172
171
 
@@ -1,30 +1,26 @@
1
1
  /**
2
- * push-notifier.ts — the platform-push **delivery** seam for strand-wake.
2
+ * push-notifier.ts — the platform-push **delivery** contract for strand-wake.
3
3
  *
4
- * Given a resolved mobile peer token, send one `strand-wake` data message over
5
- * the right platform channel (FCM for Android, APNs for iOS). This module owns no
6
- * policy — *who* to wake and *when* is the fan-out (`push-fanout.ts`). It is the
7
- * reusable, credential-injected, transport-injected unit the fan-out constructs
8
- * inside a participating `CadreNode` when push credentials are configured.
4
+ * This module is the cross-platform-safe *interface* half: the message shape
5
+ * ({@link PushMessage}), the outcome value ({@link PushSendResult}), and the
6
+ * {@link PushNotifier} port a `CadreNode` sends over. It carries ZERO runtime
7
+ * dependencies and imports no implementation — so the RN/browser entry graph can
8
+ * reference the type without ever resolving `node:crypto` / `node:http2`.
9
9
  *
10
- * `createPushNotifier` returns a router that dispatches by `msg.platform` to an
11
- * `FcmPushNotifier` and/or `ApnsPushNotifier`, constructing only the
12
- * implementations whose credentials are present. Failures are values, never
13
- * thrown — mirroring `ServiceWakeResult` / `resolveDeviceToken`'s no-throw
14
- * conventions so a best-effort fan-out can branch on the outcome.
10
+ * The concrete FCM/APNs implementations (`push-notifier-fcm.ts` /
11
+ * `push-notifier-apns.ts`) and the `createPushNotifier` router that builds them
12
+ * live behind the Node-only subpath `@serfab/cadre-core/push-node`
13
+ * (`push-node.ts`). A Node host constructs a notifier from that subpath and
14
+ * injects the instance into `CadreNodeConfig.push.notifier`; the cross-platform
15
+ * core never constructs one, so the Node-only builtins stay out of its graph.
15
16
  *
16
- * Server-only by construction: the FCM/APNs implementations reach for
17
- * `node:crypto` / `node:http2`. They are never pulled into the RN/browser bundle
18
- * because the cross-platform `index.ts` re-exports only the *types* here (a
19
- * type-only re-export is erased at emit), and the only runtime constructor of a
20
- * notifier is the in-package fan-out that runs inside a Node `CadreNode`. A
21
- * cross-platform consumer imports the payload contract + types, never this value.
17
+ * Keep this file zero-import (beyond erased type-only imports): the whole point
18
+ * of the seam is that referencing the interface can never drag an implementation
19
+ * module into a bundler's graph.
22
20
  */
23
21
 
24
- import type { PushPlatform, PushCredentials } from './types.js';
22
+ import type { PushPlatform } from './types.js';
25
23
  import type { StrandWakePayload } from './strand-wake-payload.js';
26
- import { createFcmPushNotifier, type FcmPushDeps } from './push-notifier-fcm.js';
27
- import { createApnsPushNotifier, type ApnsPushDeps } from './push-notifier-apns.js';
28
24
 
29
25
  /** One strand-wake data message addressed to one device. */
30
26
  export interface PushMessage {
@@ -54,40 +50,3 @@ export interface PushNotifier {
54
50
  /** Release transport resources (e.g. the APNs HTTP/2 session). */
55
51
  close(): Promise<void>;
56
52
  }
57
-
58
- /**
59
- * Per-platform transport/clock/log seams, injected by tests. Defaults are the
60
- * real implementations (global `fetch` for FCM, a `node:http2` session for APNs).
61
- */
62
- export interface PushNotifierDeps {
63
- fcm?: FcmPushDeps;
64
- apns?: ApnsPushDeps;
65
- }
66
-
67
- /**
68
- * Build a {@link PushNotifier} router over the configured credentials. Only the
69
- * platforms whose credentials are present get a backing implementation; a `send`
70
- * for an unconfigured platform returns a best-effort `no <platform> credentials`
71
- * failure rather than throwing.
72
- */
73
- export function createPushNotifier(creds: PushCredentials, deps: PushNotifierDeps = {}): PushNotifier {
74
- const fcm = creds.fcm ? createFcmPushNotifier(creds.fcm, deps.fcm) : undefined;
75
- const apns = creds.apns ? createApnsPushNotifier(creds.apns, deps.apns) : undefined;
76
-
77
- async function send(msg: PushMessage): Promise<PushSendResult> {
78
- if (msg.platform === 'fcm') return fcm ? fcm.send(msg) : noCreds('fcm');
79
- if (msg.platform === 'apns') return apns ? apns.send(msg) : noCreds('apns');
80
- return { ok: false, unregistered: false, error: `unknown platform ${String(msg.platform)}` };
81
- }
82
-
83
- async function close(): Promise<void> {
84
- await Promise.all([fcm?.close(), apns?.close()]);
85
- }
86
-
87
- return { send, close };
88
- }
89
-
90
- /** Best-effort no-op result for a platform with no configured credentials. */
91
- function noCreds(platform: PushPlatform): PushSendResult {
92
- return { ok: false, unregistered: false, error: `no ${platform} credentials` };
93
- }