@hodeinavarro/odoo-rpc-ts 0.2.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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +401 -0
  3. package/dist/client.d.ts +184 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +277 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/combinators/retryOnSessionExpired.d.ts +13 -0
  8. package/dist/combinators/retryOnSessionExpired.d.ts.map +1 -0
  9. package/dist/combinators/retryOnSessionExpired.js +15 -0
  10. package/dist/combinators/retryOnSessionExpired.js.map +1 -0
  11. package/dist/commands.d.ts +91 -0
  12. package/dist/commands.d.ts.map +1 -0
  13. package/dist/commands.js +69 -0
  14. package/dist/commands.js.map +1 -0
  15. package/dist/config.d.ts +32 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +60 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/domain.d.ts +30 -0
  20. package/dist/domain.d.ts.map +1 -0
  21. package/dist/domain.js +70 -0
  22. package/dist/domain.js.map +1 -0
  23. package/dist/errors/auth.d.ts +27 -0
  24. package/dist/errors/auth.d.ts.map +1 -0
  25. package/dist/errors/auth.js +8 -0
  26. package/dist/errors/auth.js.map +1 -0
  27. package/dist/errors/index.d.ts +30 -0
  28. package/dist/errors/index.d.ts.map +1 -0
  29. package/dist/errors/index.js +13 -0
  30. package/dist/errors/index.js.map +1 -0
  31. package/dist/errors/mapFault.d.ts +31 -0
  32. package/dist/errors/mapFault.d.ts.map +1 -0
  33. package/dist/errors/mapFault.js +48 -0
  34. package/dist/errors/mapFault.js.map +1 -0
  35. package/dist/errors/protocol.d.ts +22 -0
  36. package/dist/errors/protocol.d.ts.map +1 -0
  37. package/dist/errors/protocol.js +9 -0
  38. package/dist/errors/protocol.js.map +1 -0
  39. package/dist/errors/schema.d.ts +22 -0
  40. package/dist/errors/schema.d.ts.map +1 -0
  41. package/dist/errors/schema.js +14 -0
  42. package/dist/errors/schema.js.map +1 -0
  43. package/dist/errors/server.d.ts +109 -0
  44. package/dist/errors/server.d.ts.map +1 -0
  45. package/dist/errors/server.js +49 -0
  46. package/dist/errors/server.js.map +1 -0
  47. package/dist/errors/session.d.ts +16 -0
  48. package/dist/errors/session.d.ts.map +1 -0
  49. package/dist/errors/session.js +11 -0
  50. package/dist/errors/session.js.map +1 -0
  51. package/dist/errors/transport.d.ts +42 -0
  52. package/dist/errors/transport.d.ts.map +1 -0
  53. package/dist/errors/transport.js +49 -0
  54. package/dist/errors/transport.js.map +1 -0
  55. package/dist/index.d.ts +33 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +57 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/internal/platform.d.ts +12 -0
  60. package/dist/internal/platform.d.ts.map +1 -0
  61. package/dist/internal/platform.js +11 -0
  62. package/dist/internal/platform.js.map +1 -0
  63. package/dist/internal/singleFlight.d.ts +29 -0
  64. package/dist/internal/singleFlight.d.ts.map +1 -0
  65. package/dist/internal/singleFlight.js +41 -0
  66. package/dist/internal/singleFlight.js.map +1 -0
  67. package/dist/profiles.d.ts +179 -0
  68. package/dist/profiles.d.ts.map +1 -0
  69. package/dist/profiles.js +191 -0
  70. package/dist/profiles.js.map +1 -0
  71. package/dist/protocol/jsonrpc.d.ts +127 -0
  72. package/dist/protocol/jsonrpc.d.ts.map +1 -0
  73. package/dist/protocol/jsonrpc.js +117 -0
  74. package/dist/protocol/jsonrpc.js.map +1 -0
  75. package/dist/records/index.d.ts +14 -0
  76. package/dist/records/index.d.ts.map +1 -0
  77. package/dist/records/index.js +14 -0
  78. package/dist/records/index.js.map +1 -0
  79. package/dist/records/recordModel.d.ts +131 -0
  80. package/dist/records/recordModel.d.ts.map +1 -0
  81. package/dist/records/recordModel.js +101 -0
  82. package/dist/records/recordModel.js.map +1 -0
  83. package/dist/records/related.d.ts +42 -0
  84. package/dist/records/related.d.ts.map +1 -0
  85. package/dist/records/related.js +51 -0
  86. package/dist/records/related.js.map +1 -0
  87. package/dist/records/relations.d.ts +66 -0
  88. package/dist/records/relations.d.ts.map +1 -0
  89. package/dist/records/relations.js +119 -0
  90. package/dist/records/relations.js.map +1 -0
  91. package/dist/records/spec.d.ts +44 -0
  92. package/dist/records/spec.d.ts.map +1 -0
  93. package/dist/records/spec.js +63 -0
  94. package/dist/records/spec.js.map +1 -0
  95. package/dist/records/typed.d.ts +71 -0
  96. package/dist/records/typed.d.ts.map +1 -0
  97. package/dist/records/typed.js +65 -0
  98. package/dist/records/typed.js.map +1 -0
  99. package/dist/rpc.d.ts +90 -0
  100. package/dist/rpc.d.ts.map +1 -0
  101. package/dist/rpc.js +113 -0
  102. package/dist/rpc.js.map +1 -0
  103. package/dist/services/db.d.ts +128 -0
  104. package/dist/services/db.d.ts.map +1 -0
  105. package/dist/services/db.js +132 -0
  106. package/dist/services/db.js.map +1 -0
  107. package/dist/services/report.d.ts +48 -0
  108. package/dist/services/report.d.ts.map +1 -0
  109. package/dist/services/report.js +103 -0
  110. package/dist/services/report.js.map +1 -0
  111. package/dist/session/cookie.d.ts +114 -0
  112. package/dist/session/cookie.d.ts.map +1 -0
  113. package/dist/session/cookie.js +231 -0
  114. package/dist/session/cookie.js.map +1 -0
  115. package/dist/testing/fakeTransport.d.ts +40 -0
  116. package/dist/testing/fakeTransport.d.ts.map +1 -0
  117. package/dist/testing/fakeTransport.js +36 -0
  118. package/dist/testing/fakeTransport.js.map +1 -0
  119. package/dist/testing/index.d.ts +9 -0
  120. package/dist/testing/index.d.ts.map +1 -0
  121. package/dist/testing/index.js +9 -0
  122. package/dist/testing/index.js.map +1 -0
  123. package/dist/transport.d.ts +62 -0
  124. package/dist/transport.d.ts.map +1 -0
  125. package/dist/transport.js +9 -0
  126. package/dist/transport.js.map +1 -0
  127. package/dist/transports/json2.d.ts +41 -0
  128. package/dist/transports/json2.d.ts.map +1 -0
  129. package/dist/transports/json2.js +196 -0
  130. package/dist/transports/json2.js.map +1 -0
  131. package/dist/transports/jsonrpc.d.ts +41 -0
  132. package/dist/transports/jsonrpc.d.ts.map +1 -0
  133. package/dist/transports/jsonrpc.js +119 -0
  134. package/dist/transports/jsonrpc.js.map +1 -0
  135. package/dist/transports/web.d.ts +15 -0
  136. package/dist/transports/web.d.ts.map +1 -0
  137. package/dist/transports/web.js +62 -0
  138. package/dist/transports/web.js.map +1 -0
  139. package/dist/version-live.d.ts +41 -0
  140. package/dist/version-live.d.ts.map +1 -0
  141. package/dist/version-live.js +26 -0
  142. package/dist/version-live.js.map +1 -0
  143. package/dist/version.d.ts +78 -0
  144. package/dist/version.d.ts.map +1 -0
  145. package/dist/version.js +46 -0
  146. package/dist/version.js.map +1 -0
  147. package/docs/protocol-verification.md +43 -0
  148. package/package.json +71 -0
  149. package/src/client.ts +731 -0
  150. package/src/combinators/retryOnSessionExpired.ts +25 -0
  151. package/src/commands.ts +112 -0
  152. package/src/config.ts +110 -0
  153. package/src/domain.ts +91 -0
  154. package/src/errors/auth.ts +28 -0
  155. package/src/errors/index.ts +50 -0
  156. package/src/errors/mapFault.ts +92 -0
  157. package/src/errors/protocol.ts +19 -0
  158. package/src/errors/schema.ts +18 -0
  159. package/src/errors/server.ts +79 -0
  160. package/src/errors/session.ts +12 -0
  161. package/src/errors/transport.ts +74 -0
  162. package/src/index.ts +152 -0
  163. package/src/internal/platform.ts +22 -0
  164. package/src/internal/singleFlight.ts +80 -0
  165. package/src/profiles.ts +386 -0
  166. package/src/protocol/jsonrpc.ts +162 -0
  167. package/src/records/index.ts +49 -0
  168. package/src/records/recordModel.ts +211 -0
  169. package/src/records/related.ts +80 -0
  170. package/src/records/relations.ts +192 -0
  171. package/src/records/spec.ts +96 -0
  172. package/src/records/typed.ts +180 -0
  173. package/src/rpc.ts +219 -0
  174. package/src/services/db.ts +282 -0
  175. package/src/services/report.ts +165 -0
  176. package/src/session/cookie.ts +461 -0
  177. package/src/testing/fakeTransport.ts +84 -0
  178. package/src/testing/index.ts +9 -0
  179. package/src/transport.ts +72 -0
  180. package/src/transports/json2.ts +303 -0
  181. package/src/transports/jsonrpc.ts +218 -0
  182. package/src/transports/web.ts +96 -0
  183. package/src/version-live.ts +67 -0
  184. package/src/version.ts +102 -0
@@ -0,0 +1,386 @@
1
+ import { Context, Data, Effect, Layer, Option, type Redacted, Ref } from "effect";
2
+ import type { OdooConfig, OdooCredentials } from "./config.ts";
3
+
4
+ /**
5
+ * Which transport a saved profile targets. Mirrors the three seams behind the
6
+ * `Transport` tag (`web`, `/jsonrpc`, JSON-2); persisted as a stable string so a
7
+ * stored profile survives refactors of the internal dialect enum.
8
+ */
9
+ export type ProfileProtocol = "json-rpc" | "json-2" | "web";
10
+
11
+ /**
12
+ * The serializable, **secret-free** metadata of a credential-backed preset.
13
+ * `credentialKind` records which arm of {@link OdooCredentials} to
14
+ * reconstruct, so the secret can be re-homed without guessing.
15
+ */
16
+ export interface CredentialProfileData {
17
+ readonly url: string;
18
+ readonly db: string;
19
+ readonly username: string;
20
+ readonly credentialKind: "api-key" | "password";
21
+ readonly protocol: ProfileProtocol;
22
+ }
23
+
24
+ /**
25
+ * The serializable, **secret-free** metadata of a harvested-session preset:
26
+ * the stored secret is the `session_id` cookie value itself, not a credential
27
+ * (the flow exists precisely because there ARE no credentials — TOTP/SSO
28
+ * accounts cannot password-auth). No `db`/`username`: the server binds both
29
+ * to the cookie, and `get_session_info` reports them live. A session rides
30
+ * the cookie-authenticated `/web` routes, so `protocol` is always `"web"`.
31
+ */
32
+ export interface SessionProfileData {
33
+ readonly url: string;
34
+ readonly credentialKind: "session";
35
+ readonly protocol: "web";
36
+ }
37
+
38
+ /**
39
+ * The serializable, **secret-free** part of a connection preset. This is the
40
+ * ONLY shape {@link ProfileStorage} ever sees — the secret (credential or
41
+ * session cookie) lives in {@link SecretStore} and is never a field here.
42
+ * Discriminated on `credentialKind`.
43
+ */
44
+ export type ProfileData = CredentialProfileData | SessionProfileData;
45
+
46
+ /**
47
+ * A profile's storage operation failed at the consumer-supplied backend (keyring
48
+ * denied, IndexedDB transaction aborted, file unwritable, …). Wraps the opaque
49
+ * platform `cause`; upper layers branch on `_tag`, never on the message. A store
50
+ * failure proves only that *that* operation failed — infer nothing about the
51
+ * other backend's state (see the removal ordering in {@link make}).
52
+ */
53
+ export class ProfileStoreError extends Data.TaggedError("ProfileStoreError")<{
54
+ /** The logical operation that failed, e.g. `"secret.set"` / `"storage.load"`. */
55
+ readonly operation: string;
56
+ readonly cause: unknown;
57
+ }> {}
58
+
59
+ /**
60
+ * A profile's metadata was found but its credential secret is not reachable
61
+ * through {@link SecretStore} (the secret was never stored, was removed out of
62
+ * band, or the named profile does not exist at all). Distinct from
63
+ * {@link ProfileStoreError}: the backend answered fine — the secret is simply
64
+ * absent, so the {@link OdooConfig} cannot be reconstructed.
65
+ */
66
+ export class ProfileSecretMissingError extends Data.TaggedError("ProfileSecretMissingError")<{
67
+ readonly name: string;
68
+ /** The keyring account that was probed: `${name}:${credentialKind}`. */
69
+ readonly account: string;
70
+ }> {}
71
+
72
+ /**
73
+ * Consumer-implemented secret vault, keyed by an opaque `account` string. The
74
+ * platform-portable seam: an OS keyring in Node, IndexedDB + WebCrypto in a
75
+ * browser, a `Ref`-backed map in tests. Secrets cross this boundary only as
76
+ * `Redacted` — never a bare string — so they cannot leak into logs or JSON.
77
+ */
78
+ export class SecretStore extends Context.Service<
79
+ SecretStore,
80
+ {
81
+ readonly get: (
82
+ account: string,
83
+ ) => Effect.Effect<Option.Option<Redacted.Redacted<string>>, ProfileStoreError>;
84
+ readonly set: (
85
+ account: string,
86
+ secret: Redacted.Redacted<string>,
87
+ ) => Effect.Effect<void, ProfileStoreError>;
88
+ readonly remove: (account: string) => Effect.Effect<void, ProfileStoreError>;
89
+ }
90
+ >()("odoo-rpc-ts/SecretStore") {}
91
+
92
+ /**
93
+ * Consumer-implemented store for the secret-free {@link ProfileData} map, keyed
94
+ * by profile name. A JSON file on disk, `localStorage`, a config table — anything
95
+ * that round-trips a `Record<string, ProfileData>`. Never carries secrets.
96
+ */
97
+ export class ProfileStorage extends Context.Service<
98
+ ProfileStorage,
99
+ {
100
+ readonly load: () => Effect.Effect<Record<string, ProfileData>, ProfileStoreError>;
101
+ readonly save: (
102
+ profiles: Record<string, ProfileData>,
103
+ ) => Effect.Effect<void, ProfileStoreError>;
104
+ }
105
+ >()("odoo-rpc-ts/ProfileStorage") {}
106
+
107
+ /** The keyring account for a profile's secret. One secret per (name, kind). */
108
+ const accountOf = (name: string, credentialKind: ProfileData["credentialKind"]): string =>
109
+ `${name}:${credentialKind}`;
110
+
111
+ const credentialKindOf = (c: OdooCredentials): CredentialProfileData["credentialKind"] =>
112
+ c._tag === "ApiKey" ? "api-key" : "password";
113
+
114
+ const secretOf = (c: OdooCredentials): Redacted.Redacted<string> =>
115
+ c._tag === "ApiKey" ? c.apiKey : c.password;
116
+
117
+ /** Rebuild the live {@link OdooConfig} from stored metadata + a resolved secret. */
118
+ const reconstructConfig = (
119
+ data: CredentialProfileData,
120
+ secret: Redacted.Redacted<string>,
121
+ ): OdooConfig => ({
122
+ url: new URL(data.url),
123
+ db: data.db,
124
+ credentials:
125
+ data.credentialKind === "api-key"
126
+ ? { _tag: "ApiKey", username: data.username, apiKey: secret }
127
+ : { _tag: "Password", username: data.username, password: secret },
128
+ });
129
+
130
+ /**
131
+ * What {@link Profiles.loadProfile} hands back — tagged so the consumer can
132
+ * tell a credential preset from a harvested-session preset WITHOUT this
133
+ * service ever fabricating an {@link OdooConfig} around a fake credential:
134
+ *
135
+ * - `"credentials"` — a real config; feed it to `EphemeralAuth` /
136
+ * `CookieSessionLive.layerConfig` as usual.
137
+ * - `"session"` — no credentials exist; feed `url` + `sessionId` to
138
+ * `CookieSessionLive.fromExisting` (its {@link ExistingSessionOptions}
139
+ * shape) over the web transport.
140
+ */
141
+ export type LoadedProfile =
142
+ | {
143
+ readonly _tag: "credentials";
144
+ readonly config: OdooConfig;
145
+ readonly protocol: ProfileProtocol;
146
+ }
147
+ | {
148
+ readonly _tag: "session";
149
+ readonly url: URL;
150
+ readonly sessionId: Redacted.Redacted<string>;
151
+ readonly protocol: "web";
152
+ };
153
+
154
+ /**
155
+ * Named connection profiles over a {@link SecretStore} + {@link ProfileStorage}
156
+ * seam. The metadata (URL, db, username, protocol) is serializable and travels
157
+ * through `ProfileStorage`; the credential secret travels *only* through
158
+ * `SecretStore` as `Redacted`. Serializing what this service persists can never
159
+ * expose secret material.
160
+ */
161
+ export class Profiles extends Context.Service<
162
+ Profiles,
163
+ {
164
+ /**
165
+ * Persist a preset: writes the secret to {@link SecretStore} first, then the
166
+ * secret-free {@link ProfileData} to {@link ProfileStorage} (secret-before-
167
+ * metadata so stored metadata never predates its secret). Overwrites any
168
+ * existing profile of the same name.
169
+ */
170
+ readonly saveProfile: (
171
+ name: string,
172
+ config: OdooConfig,
173
+ protocol: ProfileProtocol,
174
+ ) => Effect.Effect<void, ProfileStoreError>;
175
+
176
+ /**
177
+ * Persist a harvested-session preset: the `session_id` cookie value is
178
+ * the stored secret (same secret-before-metadata ordering as
179
+ * {@link saveProfile}). No credentials are involved — this is the flow
180
+ * for TOTP/SSO accounts whose cookie was minted by a real `/web/login`
181
+ * (e.g. an embedded login window). Overwrites any existing profile of
182
+ * the same name.
183
+ */
184
+ readonly saveSessionProfile: (
185
+ name: string,
186
+ url: URL,
187
+ sessionId: Redacted.Redacted<string>,
188
+ ) => Effect.Effect<void, ProfileStoreError>;
189
+
190
+ /**
191
+ * Reconstruct a preset: reads metadata, then resolves the secret from
192
+ * {@link SecretStore}. A credential preset rebuilds the {@link OdooConfig};
193
+ * a session preset returns the harvested cookie — see {@link LoadedProfile}
194
+ * for how to consume each arm. Fails with {@link ProfileSecretMissingError}
195
+ * when the profile is unknown or its secret is absent — never returns a
196
+ * config or session with a placeholder secret.
197
+ */
198
+ readonly loadProfile: (
199
+ name: string,
200
+ ) => Effect.Effect<LoadedProfile, ProfileStoreError | ProfileSecretMissingError>;
201
+
202
+ /** All stored presets by name — secret-free by construction. */
203
+ readonly listProfiles: () => Effect.Effect<Record<string, ProfileData>, ProfileStoreError>;
204
+
205
+ /**
206
+ * Delete a preset. Removes the secret FIRST, then the metadata (see the
207
+ * ordering rationale in {@link make}). A no-op for an unknown name.
208
+ */
209
+ readonly removeProfile: (name: string) => Effect.Effect<void, ProfileStoreError>;
210
+ }
211
+ >()("odoo-rpc-ts/Profiles") {}
212
+
213
+ /** Hand-written {@link Profiles} layer over the two storage seams. */
214
+ export const layer: Layer.Layer<Profiles, never, SecretStore | ProfileStorage> = Layer.effect(
215
+ Profiles,
216
+ Effect.gen(function* () {
217
+ const secrets = yield* SecretStore;
218
+ const storage = yield* ProfileStorage;
219
+
220
+ /**
221
+ * Overwriting a profile with a DIFFERENT credentialKind must evict the
222
+ * superseded secret first, or it is orphaned in the keyring forever:
223
+ * removeProfile derives the account from the *current* metadata kind, so a
224
+ * password secret left behind by a password→session switch would be
225
+ * invisible and un-removable. Evict-before-write keeps the failure modes
226
+ * benign: if the subsequent new-secret write fails, the stored metadata
227
+ * still names the OLD kind whose secret is now gone — loadProfile then
228
+ * reports ProfileSecretMissingError rather than returning stale material.
229
+ */
230
+ const evictSuperseded = (
231
+ name: string,
232
+ newKind: ProfileData["credentialKind"],
233
+ existing: Record<string, ProfileData>,
234
+ ): Effect.Effect<void, ProfileStoreError> => {
235
+ const previous = existing[name];
236
+ return previous !== undefined && previous.credentialKind !== newKind
237
+ ? secrets.remove(accountOf(name, previous.credentialKind))
238
+ : Effect.void;
239
+ };
240
+
241
+ const saveProfile = (
242
+ name: string,
243
+ config: OdooConfig,
244
+ protocol: ProfileProtocol,
245
+ ): Effect.Effect<void, ProfileStoreError> =>
246
+ Effect.gen(function* () {
247
+ const credentialKind = credentialKindOf(config.credentials);
248
+ const data: ProfileData = {
249
+ url: config.url.href,
250
+ db: config.db,
251
+ username: config.credentials.username,
252
+ credentialKind,
253
+ protocol,
254
+ };
255
+ const existing = yield* storage.load();
256
+ yield* evictSuperseded(name, credentialKind, existing);
257
+ // Secret before metadata: if metadata landed first and the secret write
258
+ // then failed, we'd persist a profile that can never be loaded.
259
+ yield* secrets.set(accountOf(name, credentialKind), secretOf(config.credentials));
260
+ yield* storage.save({ ...existing, [name]: data });
261
+ });
262
+
263
+ const saveSessionProfile = (
264
+ name: string,
265
+ url: URL,
266
+ sessionId: Redacted.Redacted<string>,
267
+ ): Effect.Effect<void, ProfileStoreError> =>
268
+ Effect.gen(function* () {
269
+ const data: ProfileData = { url: url.href, credentialKind: "session", protocol: "web" };
270
+ const existing = yield* storage.load();
271
+ yield* evictSuperseded(name, "session", existing);
272
+ // Same ordering as saveProfile: secret (the cookie) before metadata.
273
+ yield* secrets.set(accountOf(name, "session"), sessionId);
274
+ yield* storage.save({ ...existing, [name]: data });
275
+ });
276
+
277
+ const loadProfile = (
278
+ name: string,
279
+ ): Effect.Effect<LoadedProfile, ProfileStoreError | ProfileSecretMissingError> =>
280
+ Effect.gen(function* () {
281
+ const all = yield* storage.load();
282
+ const data = all[name];
283
+ if (data === undefined) {
284
+ // No metadata ⇒ no reachable secret either.
285
+ return yield* new ProfileSecretMissingError({ name, account: name });
286
+ }
287
+ // ProfileStorage is a consumer-supplied JSON round-trip — its output is
288
+ // NOT trusted. A corrupted/hand-edited record must fail in the typed
289
+ // channel (ProfileStoreError), never throw past it (new URL TypeError,
290
+ // or a credentials arm missing username/db sailing into OdooConfig).
291
+ const url = yield* Effect.try({
292
+ try: () => new URL(data.url),
293
+ catch: (cause) => new ProfileStoreError({ operation: "profile.decode", cause }),
294
+ });
295
+ if (
296
+ data.credentialKind !== "session" &&
297
+ (typeof data.username !== "string" || typeof data.db !== "string")
298
+ ) {
299
+ return yield* new ProfileStoreError({
300
+ operation: "profile.decode",
301
+ cause: `stored profile "${name}" (${data.credentialKind}) lacks username/db`,
302
+ });
303
+ }
304
+ const account = accountOf(name, data.credentialKind);
305
+ const maybeSecret = yield* secrets.get(account);
306
+ if (Option.isNone(maybeSecret)) {
307
+ return yield* new ProfileSecretMissingError({ name, account });
308
+ }
309
+ if (data.credentialKind === "session") {
310
+ // NEVER fabricate an OdooConfig around a session cookie — there is
311
+ // no credential; the consumer feeds this to CookieSessionLive.fromExisting.
312
+ return {
313
+ _tag: "session",
314
+ url,
315
+ sessionId: maybeSecret.value,
316
+ protocol: data.protocol,
317
+ } as const;
318
+ }
319
+ return {
320
+ _tag: "credentials",
321
+ config: reconstructConfig(data, maybeSecret.value),
322
+ protocol: data.protocol,
323
+ } as const;
324
+ });
325
+
326
+ const listProfiles = (): Effect.Effect<Record<string, ProfileData>, ProfileStoreError> =>
327
+ storage.load();
328
+
329
+ const removeProfile = (name: string): Effect.Effect<void, ProfileStoreError> =>
330
+ Effect.gen(function* () {
331
+ const all = yield* storage.load();
332
+ const data = all[name];
333
+ if (data === undefined) {
334
+ return; // Nothing to remove — idempotent.
335
+ }
336
+ // Ordering: remove the SECRET first, then the metadata. The dangerous
337
+ // half-state is an orphaned secret — a credential left in the keyring
338
+ // with no metadata pointing at it, invisible and un-removable. Deleting
339
+ // the secret first guarantees the credential is gone even if the
340
+ // subsequent metadata save fails. The reverse transient (metadata
341
+ // without a secret) is benign and self-heals: loadProfile reports
342
+ // ProfileSecretMissingError rather than handing back a broken config.
343
+ yield* secrets.remove(accountOf(name, data.credentialKind));
344
+ const { [name]: _removed, ...rest } = all;
345
+ yield* storage.save(rest);
346
+ });
347
+
348
+ return { saveProfile, saveSessionProfile, loadProfile, listProfiles, removeProfile };
349
+ }),
350
+ );
351
+
352
+ /**
353
+ * Ready-made, `Ref`-backed {@link SecretStore} for tests and single-process
354
+ * consumers. Each `.layer` build gets its own isolated map — no shared global
355
+ * state between tests.
356
+ */
357
+ export const InMemorySecretStore = {
358
+ layer: Layer.effect(
359
+ SecretStore,
360
+ Effect.gen(function* () {
361
+ const store = yield* Ref.make<Record<string, Redacted.Redacted<string>>>({});
362
+ return {
363
+ get: (account) => Ref.get(store).pipe(Effect.map((s) => Option.fromNullishOr(s[account]))),
364
+ set: (account, secret) => Ref.update(store, (s) => ({ ...s, [account]: secret })),
365
+ remove: (account) => Ref.update(store, ({ [account]: _removed, ...rest }) => rest),
366
+ };
367
+ }),
368
+ ),
369
+ } as const;
370
+
371
+ /**
372
+ * Ready-made, `Ref`-backed {@link ProfileStorage} for tests and single-process
373
+ * consumers. Isolated per `.layer` build, like {@link InMemorySecretStore}.
374
+ */
375
+ export const InMemoryProfileStorage = {
376
+ layer: Layer.effect(
377
+ ProfileStorage,
378
+ Effect.gen(function* () {
379
+ const store = yield* Ref.make<Record<string, ProfileData>>({});
380
+ return {
381
+ load: () => Ref.get(store),
382
+ save: (profiles) => Ref.set(store, profiles),
383
+ };
384
+ }),
385
+ ),
386
+ } as const;
@@ -0,0 +1,162 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { FaultCallSite } from "../errors/mapFault.ts";
3
+ import { mapServerFault, type RawServerFault } from "../errors/mapFault.ts";
4
+ import { OdooServerError, type OdooServerFault } from "../errors/server.ts";
5
+ import type { OdooAuthenticationError } from "../errors/auth.ts";
6
+ import { SessionExpiredError } from "../errors/session.ts";
7
+
8
+ /**
9
+ * The JSON-RPC envelope shared by every route that speaks it (`/jsonrpc`,
10
+ * `/web/session/authenticate`, `/web/dataset/call_kw`). Deliberately
11
+ * transport-agnostic: `params` is opaque here so the same builder serves the
12
+ * `object`/`common` service calls and the web route's `{model, method, ...}`
13
+ * params alike.
14
+ *
15
+ * GOTCHA: Odoo ignores the JSON-RPC `method` member entirely — it dispatches on
16
+ * the URL and reads `params` by name. We still send the spec-mandated
17
+ * `"call"` so the envelope is valid JSON-RPC 2.0.
18
+ */
19
+ export interface JsonRpcRequest<P> {
20
+ readonly jsonrpc: "2.0";
21
+ readonly method: "call";
22
+ readonly params: P;
23
+ readonly id: number;
24
+ }
25
+
26
+ // GOTCHA: Odoo ignores the JSON-RPC `id` entirely — it never round-trips into
27
+ // dispatch. This process-wide monotonic counter exists only to keep concurrent
28
+ // requests distinguishable in logs/traces; module-level mutable state is
29
+ // acceptable precisely because nothing correctness-bearing reads it back.
30
+ let requestIdCounter = 0;
31
+
32
+ /** Next distinct JSON-RPC request id (log-correlation only; see gotcha above). */
33
+ export const nextRequestId = (): number => (requestIdCounter += 1);
34
+
35
+ /** Build a JSON-RPC 2.0 request envelope around arbitrary by-name `params`. */
36
+ export const buildRequest = <P>(params: P, id: number): JsonRpcRequest<P> => ({
37
+ jsonrpc: "2.0",
38
+ method: "call",
39
+ params,
40
+ id,
41
+ });
42
+
43
+ /**
44
+ * The `error.data` payload. Odoo guarantees only `name` (the dotted Python
45
+ * exception class); every other field can be absent depending on the fault and
46
+ * the server's `dev_mode`. Decoded leniently, then normalized to
47
+ * ""/[]/{} before it reaches {@link mapServerFault}.
48
+ */
49
+ export const JsonRpcErrorData = Schema.Struct({
50
+ name: Schema.String,
51
+ debug: Schema.optional(Schema.String),
52
+ message: Schema.optional(Schema.String),
53
+ arguments: Schema.optional(Schema.Array(Schema.Unknown)),
54
+ context: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
55
+ });
56
+ export type JsonRpcErrorData = typeof JsonRpcErrorData.Type;
57
+
58
+ /** The `error` member of a JSON-RPC error response. `data` may be absent. */
59
+ export const JsonRpcErrorPayload = Schema.Struct({
60
+ code: Schema.Number,
61
+ message: Schema.String,
62
+ data: Schema.optional(JsonRpcErrorData),
63
+ });
64
+ export type JsonRpcErrorPayload = typeof JsonRpcErrorPayload.Type;
65
+
66
+ // `id` echoes what we sent, but a few Odoo error paths return `null` — accept
67
+ // any of the JSON-RPC-legal id shapes rather than drift on the envelope.
68
+ const JsonRpcId = Schema.Union([Schema.Number, Schema.String, Schema.Null]);
69
+
70
+ /** A JSON-RPC error response: `{jsonrpc, id?, error:{code, message, data?}}`. */
71
+ export const JsonRpcErrorResponse = Schema.Struct({
72
+ jsonrpc: Schema.Literal("2.0"),
73
+ id: Schema.optional(JsonRpcId),
74
+ error: JsonRpcErrorPayload,
75
+ });
76
+ export type JsonRpcErrorResponse = typeof JsonRpcErrorResponse.Type;
77
+
78
+ /**
79
+ * A JSON-RPC success response: `{jsonrpc, id?, result?}` (result is opaque).
80
+ *
81
+ * Odoo 16 and 17 omit the member entirely when a Python method returns
82
+ * `None`. Decode that wire quirk to an explicit `undefined`, so consumers have
83
+ * one stable successful-response shape.
84
+ */
85
+ const JsonRpcResultSuccessResponse = Schema.Struct({
86
+ jsonrpc: Schema.Literal("2.0"),
87
+ id: Schema.optional(JsonRpcId),
88
+ result: Schema.Unknown,
89
+ error: Schema.optionalKey(Schema.Never),
90
+ });
91
+
92
+ const JsonRpcVoidSuccessResponse = Schema.Struct({
93
+ jsonrpc: Schema.Literal("2.0"),
94
+ id: JsonRpcId,
95
+ result: Schema.Unknown.pipe(Schema.withDecodingDefaultTypeKey(Effect.void)),
96
+ // Declare this forbidden member so malformed error envelopes cannot fall
97
+ // through to the missing-result success shape in the union below.
98
+ error: Schema.optionalKey(Schema.Never),
99
+ });
100
+ export const JsonRpcSuccessResponse = Schema.Union([
101
+ JsonRpcResultSuccessResponse,
102
+ JsonRpcVoidSuccessResponse,
103
+ ]);
104
+ export type JsonRpcSuccessResponse = typeof JsonRpcSuccessResponse.Type;
105
+
106
+ /**
107
+ * A whole JSON-RPC response. The error branch is tried first so a fault is
108
+ * never mistaken for a `result: undefined` success. A body that is neither
109
+ * shape (e.g. a proxy's HTML error page decoded as garbage) fails to decode →
110
+ * the caller raises `SchemaDriftError`.
111
+ */
112
+ export const JsonRpcResponse = Schema.Union([JsonRpcErrorResponse, JsonRpcSuccessResponse]);
113
+ export type JsonRpcResponse = typeof JsonRpcResponse.Type;
114
+
115
+ /** Normalize a lenient `error.data` (or its absence) into a {@link RawServerFault}. */
116
+ const normalizeFault = (
117
+ data: JsonRpcErrorData | undefined,
118
+ fallbackMessage: string,
119
+ ): RawServerFault => ({
120
+ name: data?.name ?? "unknown",
121
+ message: data?.message ?? fallbackMessage,
122
+ arguments: data?.arguments ?? [],
123
+ context: data?.context ?? {},
124
+ // exactOptionalPropertyTypes: attach `debug` only when actually present.
125
+ ...(data?.debug !== undefined ? { debug: data.debug } : {}),
126
+ });
127
+
128
+ /**
129
+ * The single JSON-RPC fault choke point. Both `/jsonrpc` and the web route feed
130
+ * their decoded `error` payload through here so every fault normalizes into the
131
+ * same tagged union.
132
+ *
133
+ * - code `100` → {@link SessionExpiredError} (Odoo's `SessionExpiredException`).
134
+ * - code `404` → {@link OdooServerError} (NotFound; the `data.name` is often a
135
+ * werkzeug class we do not map, so it lands on the base fault by design).
136
+ * - otherwise → {@link mapServerFault} on the normalized `data`; a missing
137
+ * `data` becomes name `"unknown"`, which falls back to {@link OdooServerError}.
138
+ */
139
+ export const mapJsonRpcError = (
140
+ error: JsonRpcErrorPayload,
141
+ site: FaultCallSite = {},
142
+ ): SessionExpiredError | OdooServerFault | OdooAuthenticationError => {
143
+ if (error.code === 100) {
144
+ return new SessionExpiredError({ message: error.message });
145
+ }
146
+
147
+ const raw = normalizeFault(error.data, error.message);
148
+
149
+ if (error.code === 404) {
150
+ return new OdooServerError({
151
+ name: raw.name,
152
+ message: raw.message,
153
+ arguments: raw.arguments,
154
+ context: raw.context,
155
+ ...(raw.debug !== undefined ? { debug: raw.debug } : {}),
156
+ ...(site.model !== undefined ? { model: site.model } : {}),
157
+ ...(site.method !== undefined ? { method: site.method } : {}),
158
+ });
159
+ }
160
+
161
+ return mapServerFault(raw, site);
162
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Typed records — internal barrel for both per-protocol tiers of the records
3
+ * design: the CLASSIC tier (explicit traversal over the classic `[id, name]`
4
+ * pair protocol, 16+: relation/temporal decode schemas, pure join helpers,
5
+ * the `TypedRecordSet` snapshot) and the SPEC tier (declared prefetch over the
6
+ * 17+ `specification` protocol: `defineRecord` + the specification compiler).
7
+ * Re-exported to the public surface via `src/index.ts`.
8
+ */
9
+ export {
10
+ Many2OneRef,
11
+ Many2OneRefFromWire,
12
+ Many2OneRefOrNull,
13
+ Many2OneRefValue,
14
+ OdooDate,
15
+ OdooDateOrNull,
16
+ OdooDateTime,
17
+ OdooDateTimeOrNull,
18
+ } from "./relations.ts";
19
+ export {
20
+ collectRefIds,
21
+ makeRelatedMap,
22
+ refId,
23
+ type RefOrId,
24
+ type RelatedMap,
25
+ } from "./related.ts";
26
+ export {
27
+ make as makeTypedRecordSet,
28
+ type HasId,
29
+ type Many2OneRefField,
30
+ type TypedRecordSet,
31
+ } from "./typed.ts";
32
+ export {
33
+ defineRecord,
34
+ Many2One,
35
+ One2Many,
36
+ type FieldInput,
37
+ type FieldMeta,
38
+ type Many2OneDecl,
39
+ type One2ManyDecl,
40
+ type RecordSpec,
41
+ type RowEncoded,
42
+ type RowType,
43
+ } from "./recordModel.ts";
44
+ export {
45
+ compileSpecification,
46
+ EmptyRecordSpecError,
47
+ hasRelations,
48
+ RecordSpecCycleError,
49
+ } from "./spec.ts";