@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,165 @@
1
+ import { Effect, Result, Schema } from "effect";
2
+ import { OdooAccessError, OdooMissingError } from "../errors/server.ts";
3
+ import { SchemaDriftError } from "../errors/schema.ts";
4
+ import { OdooTransportError, type RequestInfo } from "../errors/transport.ts";
5
+ import type { CookieLoginError, CookieSessionService } from "../session/cookie.ts";
6
+
7
+ /**
8
+ * A decoded `ir.actions.report` row. Field names are normalized to camelCase at
9
+ * the schema boundary so callers never touch Odoo's snake_case wire shape.
10
+ */
11
+ export interface ReportAction {
12
+ readonly id: number;
13
+ readonly name: string;
14
+ readonly reportName: string;
15
+ readonly model: string;
16
+ readonly reportType: string;
17
+ }
18
+
19
+ /**
20
+ * Lenient decoder for an `ir.actions.report` row. Only the columns we request
21
+ * are decoded; extra keys are tolerated. `report_name` is the identifier that
22
+ * feeds the `GET /report/<converter>/<report_name>/<ids>` download route.
23
+ */
24
+ const ReportActionSchema = Schema.Struct({
25
+ id: Schema.Number,
26
+ name: Schema.String,
27
+ report_name: Schema.String,
28
+ model: Schema.String,
29
+ report_type: Schema.String,
30
+ });
31
+
32
+ const toReportAction = (row: typeof ReportActionSchema.Type): ReportAction => ({
33
+ id: row.id,
34
+ name: row.name,
35
+ reportName: row.report_name,
36
+ model: row.model,
37
+ reportType: row.report_type,
38
+ });
39
+
40
+ /**
41
+ * List `ir.actions.report` definitions over a cookie session, optionally scoped
42
+ * to a single `model`. Self-contained: goes through the session's `json` hatch
43
+ * (`web/dataset/call_kw` → `search_read`) so it needs no `Transport`/`Rpc`
44
+ * wiring. Fails with the session's `CookieLoginError` union (login faults,
45
+ * server faults, or `SchemaDriftError` on a drifted row shape).
46
+ */
47
+ export const list = (
48
+ session: CookieSessionService,
49
+ model?: string,
50
+ ): Effect.Effect<ReadonlyArray<ReportAction>, CookieLoginError> =>
51
+ Effect.gen(function* () {
52
+ const domain = model === undefined ? [] : [["model", "=", model]];
53
+ const result = yield* session.json("web/dataset/call_kw", {
54
+ model: "ir.actions.report",
55
+ method: "search_read",
56
+ args: [domain, ["id", "name", "report_name", "model", "report_type"]],
57
+ kwargs: {},
58
+ });
59
+
60
+ const rows = yield* Schema.decodeUnknownEffect(Schema.Array(ReportActionSchema))(result).pipe(
61
+ Effect.mapError(
62
+ (cause) =>
63
+ new SchemaDriftError({ context: "ir.actions.report rows", payload: result, cause }),
64
+ ),
65
+ );
66
+
67
+ return rows.map(toReportAction);
68
+ });
69
+
70
+ /**
71
+ * Options for {@link download}. `converter` is the report renderer segment of
72
+ * the download URL (`pdf` by default; `html`, `text`, … are also valid).
73
+ */
74
+ export interface DownloadOptions {
75
+ readonly reportName: string;
76
+ readonly ids: ReadonlyArray<number>;
77
+ readonly converter?: string;
78
+ }
79
+
80
+ /** The `%PDF` signature every well-formed PDF opens with. */
81
+ const PDF_MAGIC = Uint8Array.of(0x25, 0x50, 0x44, 0x46);
82
+
83
+ const hasPdfMagic = (bytes: Uint8Array): boolean =>
84
+ bytes.length >= PDF_MAGIC.length && PDF_MAGIC.every((byte, i) => bytes[i] === byte);
85
+
86
+ /**
87
+ * Download a rendered report as raw bytes via `GET
88
+ * /report/<converter>/<reportName>/<ids.join(",")>` on the cookie-bound client
89
+ * (the route is identical and CSRF-free across Odoo 16–19).
90
+ *
91
+ * Status mapping: `404 → OdooMissingError`, `403 → OdooAccessError`, any other
92
+ * non-2xx (including the `500` Odoo raises for an unknown report name) →
93
+ * `OdooTransportError`. For `converter === "pdf"` the 2xx body is checked for
94
+ * the `%PDF` magic and fails as `SchemaDriftError` if the server handed back
95
+ * something else (e.g. an HTML error page with a 200).
96
+ */
97
+ export const download = (
98
+ session: CookieSessionService,
99
+ options: DownloadOptions,
100
+ ): Effect.Effect<Uint8Array, CookieLoginError> =>
101
+ Effect.gen(function* () {
102
+ const converter = options.converter ?? "pdf";
103
+ const path = `report/${converter}/${options.reportName}/${options.ids.join(",")}`;
104
+
105
+ const response = yield* session.http({ path, method: "GET" });
106
+ const request: RequestInfo = { method: "GET", url: response.request.url };
107
+
108
+ if (response.status === 404) {
109
+ return yield* Effect.fail(
110
+ new OdooMissingError({
111
+ name: "werkzeug.exceptions.NotFound",
112
+ message: `Report ${options.reportName} not found (404) for ids ${options.ids.join(",")}.`,
113
+ arguments: [],
114
+ context: {},
115
+ }),
116
+ );
117
+ }
118
+
119
+ if (response.status === 403) {
120
+ return yield* Effect.fail(
121
+ new OdooAccessError({
122
+ name: "werkzeug.exceptions.Forbidden",
123
+ message: `Access denied (403) downloading report ${options.reportName}.`,
124
+ arguments: [],
125
+ context: {},
126
+ }),
127
+ );
128
+ }
129
+
130
+ if (response.status < 200 || response.status >= 300) {
131
+ return yield* Effect.fail(
132
+ new OdooTransportError({
133
+ request,
134
+ kind: "StatusCodeError",
135
+ status: response.status,
136
+ }),
137
+ );
138
+ }
139
+
140
+ const buffer = yield* response.arrayBuffer.pipe(
141
+ Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)),
142
+ );
143
+ const bytes = new Uint8Array(buffer);
144
+
145
+ if (converter === "pdf" && !hasPdfMagic(bytes)) {
146
+ // Manufacture a real ParseError so SchemaDriftError.cause is honest: a 2xx
147
+ // body that is not a PDF (e.g. an HTML error page served with status 200).
148
+ const header = new TextDecoder().decode(bytes.subarray(0, PDF_MAGIC.length));
149
+ const parsed = Schema.decodeUnknownResult(Schema.Literal("%PDF"))(header);
150
+ if (Result.isFailure(parsed)) {
151
+ return yield* Effect.fail(
152
+ new SchemaDriftError({
153
+ context: `report/pdf/${options.reportName}`,
154
+ payload: `non-PDF body (${bytes.length} bytes, status ${response.status})`,
155
+ cause: parsed.failure,
156
+ }),
157
+ );
158
+ }
159
+ }
160
+
161
+ return bytes;
162
+ });
163
+
164
+ /** The report service, grouped for `ReportService.list` / `ReportService.download`. */
165
+ export const ReportService = { list, download } as const;
@@ -0,0 +1,461 @@
1
+ import { type Config, Context, Effect, Layer, Option, Redacted, Ref, Schema } from "effect";
2
+ import { OdooConfig } from "../config.ts";
3
+ import { OdooAuthenticationError } from "../errors/auth.ts";
4
+ import type { OdooServerFault } from "../errors/server.ts";
5
+ import { SchemaDriftError } from "../errors/schema.ts";
6
+ import { SessionExpiredError } from "../errors/session.ts";
7
+ import { OdooTransportError, type RequestInfo } from "../errors/transport.ts";
8
+ import {
9
+ Cookies,
10
+ HttpClient,
11
+ HttpClientRequest,
12
+ HttpClientResponse,
13
+ } from "../internal/platform.ts";
14
+ import * as SingleFlight from "../internal/singleFlight.ts";
15
+ import {
16
+ buildRequest,
17
+ JsonRpcResponse,
18
+ mapJsonRpcError,
19
+ nextRequestId,
20
+ } from "../protocol/jsonrpc.ts";
21
+
22
+ /**
23
+ * Join a request path onto the configured base URL, preserving any base path
24
+ * (e.g. a server mounted under `/odoo`). Keeping this here means both the
25
+ * authenticate call and the `call_kw` transport derive endpoints identically.
26
+ */
27
+ export const joinPath = (base: URL, path: string): string => {
28
+ const href = base.href.endsWith("/") ? base.href : `${base.href}/`;
29
+ return new URL(path, href).href;
30
+ };
31
+
32
+ /**
33
+ * `session_info` as returned by `/web/session/authenticate`. Decoded leniently:
34
+ * `uid` is `null` while a second factor is still pending, extra keys are
35
+ * tolerated, and `server_version_info` passes through undecoded.
36
+ */
37
+ const SessionInfoSchema = Schema.Struct({
38
+ uid: Schema.NullOr(Schema.Number),
39
+ user_context: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
40
+ server_version_info: Schema.optional(Schema.Unknown),
41
+ });
42
+
43
+ /** A live, authenticated web session. `uid` is always a real user id here. */
44
+ export interface OdooSessionInfo {
45
+ readonly uid: number;
46
+ readonly userContext: Record<string, unknown>;
47
+ readonly serverVersionInfo: unknown;
48
+ /** The full, undecoded `session_info` result for downstream passthrough. */
49
+ readonly raw: Record<string, unknown>;
50
+ }
51
+
52
+ /**
53
+ * The typed failure channel of a login round trip. `SessionExpiredError` is in
54
+ * the union only because it flows through the shared `mapJsonRpcError`; a fresh
55
+ * authenticate should never actually produce it.
56
+ */
57
+ export type CookieLoginError =
58
+ | OdooTransportError
59
+ | SchemaDriftError
60
+ | OdooAuthenticationError
61
+ | OdooServerFault
62
+ | SessionExpiredError;
63
+
64
+ /**
65
+ * The cookie-session service. Owns the `session_id` cookie jar and a
66
+ * success-only single-flight around `/web/session/authenticate`.
67
+ *
68
+ * `client` is the HttpClient decorated with the shared cookie `Ref` — the web
69
+ * transport MUST reuse it so the login `Set-Cookie` (and any mid-session
70
+ * rotation) is carried on every subsequent `call_kw`.
71
+ */
72
+ /**
73
+ * Options for {@link CookieSessionService.http}: a raw HTTP round trip on the
74
+ * cookie-bound client. `path` is joined onto the session's base url (a base
75
+ * mount like `/odoo` is preserved). `method` defaults to `GET`.
76
+ */
77
+ export interface RawHttpOptions {
78
+ readonly path: string;
79
+ readonly method?: string;
80
+ readonly body?: Uint8Array;
81
+ readonly headers?: Record<string, string>;
82
+ }
83
+
84
+ export interface CookieSessionService {
85
+ /** Ensure a login has run and return the live session. Single-flight. */
86
+ readonly login: Effect.Effect<OdooSessionInfo, CookieLoginError>;
87
+ /**
88
+ * Raw JSON-RPC escape hatch: ensure login, then POST the shared JSON-RPC
89
+ * envelope (`{jsonrpc, method:"call", params, id}`) to an arbitrary `path`
90
+ * under the session's base url and return the raw `result` member. Server
91
+ * faults flow through the shared `mapJsonRpcError` choke point (code 100 →
92
+ * `SessionExpiredError`, etc.); a non-envelope body fails as `SchemaDriftError`.
93
+ * For calling models this is `web/dataset/call_kw` with the `{model, method,
94
+ * args, kwargs}` params shape.
95
+ */
96
+ readonly json: (
97
+ path: string,
98
+ params?: Record<string, unknown>,
99
+ ) => Effect.Effect<unknown, CookieLoginError>;
100
+ /**
101
+ * Raw HTTP escape hatch: ensure login, then issue a raw request on the
102
+ * cookie-bound client and return the response UNTOUCHED. The caller owns the
103
+ * response fully, including any non-2xx status — this hatch never inspects it
104
+ * and only fails (`OdooTransportError`) on a wire-level error or a failed
105
+ * login. Used for binary endpoints like `GET /report/<converter>/...`.
106
+ */
107
+ readonly http: (
108
+ options: RawHttpOptions,
109
+ ) => Effect.Effect<HttpClientResponse.HttpClientResponse, CookieLoginError>;
110
+ /**
111
+ * Drop the cached session and the (now-dead) `session_id` cookie so the next
112
+ * `login` re-authenticates. Does NOT auto-relogin — that is the opt-in
113
+ * `retryOnSessionExpired` combinator's job.
114
+ */
115
+ readonly invalidate: Effect.Effect<void>;
116
+ /** The shared cookie jar. Exposed for the transport's `withCookiesRef`. */
117
+ readonly cookies: Ref.Ref<Cookies.Cookies>;
118
+ /** HttpClient bound to the shared cookie jar; the transport reuses this. */
119
+ readonly client: HttpClient.HttpClient;
120
+ /** Peek at the current session without triggering a login (observability). */
121
+ readonly peek: Effect.Effect<Option.Option<OdooSessionInfo>>;
122
+ }
123
+
124
+ export class CookieSession extends Context.Service<
125
+ CookieSession,
126
+ CookieSessionService
127
+ >()("odoo-rpc-ts/CookieSession") {}
128
+
129
+ /**
130
+ * One session-info round trip: POST a JSON-RPC envelope, decode the response,
131
+ * map faults through the shared choke point (code 100 → `SessionExpiredError`),
132
+ * and validate the returned `session_info`. Shared by the credential login and
133
+ * the injected-cookie path so both decode — and fail — identically.
134
+ */
135
+ const sessionInfoRoundTrip = (
136
+ client: HttpClient.HttpClient,
137
+ url: string,
138
+ params: unknown,
139
+ endpoint: string,
140
+ method: string,
141
+ ): Effect.Effect<OdooSessionInfo, CookieLoginError> =>
142
+ Effect.gen(function* () {
143
+ const request: RequestInfo = { method: "POST", url };
144
+ const envelope = buildRequest(params, nextRequestId());
145
+
146
+ const response = yield* client
147
+ .execute(HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.post(url), envelope))
148
+ .pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
149
+
150
+ const body = yield* response.json.pipe(
151
+ Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)),
152
+ );
153
+
154
+ const decoded = yield* Schema.decodeUnknownEffect(JsonRpcResponse)(body).pipe(
155
+ Effect.mapError(
156
+ (cause) =>
157
+ new SchemaDriftError({
158
+ context: `${endpoint} envelope`,
159
+ payload: body,
160
+ cause,
161
+ }),
162
+ ),
163
+ );
164
+
165
+ if ("error" in decoded) {
166
+ return yield* Effect.fail(mapJsonRpcError(decoded.error, { method }));
167
+ }
168
+
169
+ const info = yield* Schema.decodeUnknownEffect(SessionInfoSchema)(decoded.result).pipe(
170
+ Effect.mapError(
171
+ (cause) =>
172
+ new SchemaDriftError({
173
+ context: `${endpoint} session_info`,
174
+ payload: decoded.result,
175
+ cause,
176
+ }),
177
+ ),
178
+ );
179
+
180
+ if (info.uid === null) {
181
+ return yield* Effect.fail(
182
+ new OdooAuthenticationError({
183
+ reason: "mfa-pending",
184
+ message: "Login accepted but a second factor is required before a session exists.",
185
+ }),
186
+ );
187
+ }
188
+
189
+ const session: OdooSessionInfo = {
190
+ uid: info.uid,
191
+ userContext: info.user_context ?? {},
192
+ serverVersionInfo: info.server_version_info,
193
+ raw:
194
+ typeof decoded.result === "object" && decoded.result !== null
195
+ ? (decoded.result as Record<string, unknown>)
196
+ : {},
197
+ };
198
+ return session;
199
+ });
200
+
201
+ /**
202
+ * Build the two raw escape hatches (`json`, `http`) shared by {@link make} and
203
+ * {@link fromExisting}. Both close over the SAME cookie-bound `client`, base
204
+ * `url`, and single-flight `login` the rest of the service uses, so a hatch
205
+ * request rides the live `session_id` cookie exactly like a `call_kw` does.
206
+ */
207
+ const makeRawHatches = (
208
+ client: HttpClient.HttpClient,
209
+ baseUrl: URL,
210
+ login: Effect.Effect<OdooSessionInfo, CookieLoginError>,
211
+ ): Pick<CookieSessionService, "json" | "http"> => {
212
+ const json = (
213
+ path: string,
214
+ params: Record<string, unknown> = {},
215
+ ): Effect.Effect<unknown, CookieLoginError> =>
216
+ Effect.gen(function* () {
217
+ yield* login;
218
+
219
+ const url = joinPath(baseUrl, path);
220
+ const request: RequestInfo = { method: "POST", url };
221
+ const envelope = buildRequest(params, nextRequestId());
222
+
223
+ const response = yield* client
224
+ .execute(HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.post(url), envelope))
225
+ .pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
226
+
227
+ const body = yield* response.json.pipe(
228
+ Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)),
229
+ );
230
+
231
+ const decoded = yield* Schema.decodeUnknownEffect(JsonRpcResponse)(body).pipe(
232
+ Effect.mapError(
233
+ (cause) => new SchemaDriftError({ context: `${path} envelope`, payload: body, cause }),
234
+ ),
235
+ );
236
+
237
+ if ("error" in decoded) {
238
+ return yield* Effect.fail(mapJsonRpcError(decoded.error, { method: path }));
239
+ }
240
+
241
+ return decoded.result;
242
+ });
243
+
244
+ const http = (
245
+ options: RawHttpOptions,
246
+ ): Effect.Effect<HttpClientResponse.HttpClientResponse, CookieLoginError> =>
247
+ Effect.gen(function* () {
248
+ yield* login;
249
+
250
+ const url = joinPath(baseUrl, options.path);
251
+ const method = options.method ?? "GET";
252
+ const request: RequestInfo = { method, url };
253
+
254
+ let req = HttpClientRequest.make(method as Parameters<typeof HttpClientRequest.make>[0])(url);
255
+ if (options.headers !== undefined) {
256
+ req = HttpClientRequest.setHeaders(req, options.headers);
257
+ }
258
+ if (options.body !== undefined) {
259
+ req = HttpClientRequest.bodyUint8Array(req, options.body);
260
+ }
261
+
262
+ // Response returned untouched — the caller owns any non-2xx status.
263
+ return yield* client
264
+ .execute(req)
265
+ .pipe(Effect.mapError((cause) => OdooTransportError.fromHttpClientError(request, cause)));
266
+ });
267
+
268
+ return { json, http };
269
+ };
270
+
271
+ export const make = (
272
+ config: OdooConfig,
273
+ ): Effect.Effect<CookieSessionService, never, HttpClient.HttpClient> =>
274
+ Effect.gen(function* () {
275
+ const baseClient = yield* HttpClient.HttpClient;
276
+ const cookies = yield* Ref.make(Cookies.empty);
277
+ const client = HttpClient.withCookiesRef(baseClient, cookies);
278
+
279
+ const authUrl = joinPath(config.url, "web/session/authenticate");
280
+
281
+ const doLogin: Effect.Effect<OdooSessionInfo, CookieLoginError> = Effect.suspend(() => {
282
+ // Redacted.value only at the wire boundary; for ApiKey creds the key is
283
+ // sent as the password (valid on all supported Odoo versions).
284
+ const password =
285
+ config.credentials._tag === "ApiKey"
286
+ ? Redacted.value(config.credentials.apiKey)
287
+ : Redacted.value(config.credentials.password);
288
+
289
+ return sessionInfoRoundTrip(
290
+ client,
291
+ authUrl,
292
+ { db: config.db, login: config.credentials.username, password },
293
+ "web/session/authenticate",
294
+ "authenticate",
295
+ );
296
+ });
297
+
298
+ // The flight owns both caching and invalidation. Its generation counter
299
+ // guarantees a login already in flight when `invalidate` runs cannot
300
+ // resurrect the cache with a stale session (its caller still gets it).
301
+ const flight = yield* SingleFlight.make(doLogin);
302
+
303
+ const invalidate = Effect.gen(function* () {
304
+ yield* Ref.set(cookies, Cookies.empty);
305
+ yield* flight.invalidate;
306
+ });
307
+
308
+ return {
309
+ login: flight.get,
310
+ invalidate,
311
+ cookies,
312
+ client,
313
+ peek: flight.peek,
314
+ ...makeRawHatches(client, config.url, flight.get),
315
+ };
316
+ });
317
+
318
+ /** Provide `CookieSession` from an already-resolved config. Requires `HttpClient`. */
319
+ export const layer = (
320
+ config: OdooConfig,
321
+ ): Layer.Layer<CookieSession, never, HttpClient.HttpClient> =>
322
+ Layer.effect(CookieSession, make(config));
323
+
324
+ /** Provide `CookieSession`, resolving `OdooConfig` from the environment. */
325
+ export const layerConfig: Layer.Layer<
326
+ CookieSession,
327
+ Config.ConfigError,
328
+ HttpClient.HttpClient
329
+ > = Layer.effect(CookieSession, Effect.flatMap(OdooConfig, make));
330
+
331
+ /**
332
+ * Options for {@link fromExisting}: adopt a `session_id` cookie that was
333
+ * minted elsewhere (e.g. harvested from an embedded browser window after the
334
+ * user completed the real `/web/login` page — the only stock flow that works
335
+ * for TOTP/SSO accounts).
336
+ */
337
+ export interface ExistingSessionOptions {
338
+ /** Base server URL (same shape as `OdooConfig.url`; TLS rules apply). */
339
+ readonly url: URL;
340
+ /** The harvested `session_id` cookie value. Redacted until the wire. */
341
+ readonly sessionId: Redacted.Redacted<string>;
342
+ /**
343
+ * Optional renewal hook: an Effect that yields a FRESH `session_id` (e.g.
344
+ * by driving a login window again). Runs inside the login single-flight
345
+ * after an `invalidate`, so concurrent callers trigger it at most once.
346
+ * Must be fully provided (`R = never`) and fail within `CookieLoginError` —
347
+ * map UI-level failures (window closed, …) into `OdooAuthenticationError`.
348
+ */
349
+ readonly renew?: Effect.Effect<Redacted.Redacted<string>, CookieLoginError>;
350
+ }
351
+
352
+ /**
353
+ * Build a `CookieSessionService` from an already-minted `session_id` cookie —
354
+ * no credentials are ever held. `login` seeds the cookie jar and validates the
355
+ * session via `POST /web/session/get_session_info` (verified 16–19: JSON-RPC
356
+ * envelope, `auth="user"`), so `OdooSessionInfo` (`uid`, context, version) is
357
+ * always real, never fabricated. Success-only single-flight, like {@link make}.
358
+ *
359
+ * Expired-session contract: this session cannot re-login by itself. When the
360
+ * server kills the session, calls fail with `SessionExpiredError`; after
361
+ * `invalidate`, a subsequent `login` fails FAST with `SessionExpiredError`
362
+ * (no network) unless a `renew` hook was provided. `retryOnSessionExpired`
363
+ * therefore performs exactly one relogin attempt and propagates the second
364
+ * `SessionExpiredError` — it can never spin. The consumer's shell is expected
365
+ * to catch it, re-run its login UI, and construct a fresh session (or supply
366
+ * `renew` to do the same in place).
367
+ */
368
+ // RFC 6265 `cookie-octet`: the exact charset a cookie value may put on the
369
+ // wire (excludes controls, whitespace, DQUOTE, comma, semicolon, backslash).
370
+ const cookieOctets = /^[\u0021\u0023-\u002b\u002d-\u003a\u003c-\u005b\u005d-\u007e]+$/;
371
+
372
+ export const fromExisting = (
373
+ options: ExistingSessionOptions,
374
+ ): Effect.Effect<CookieSessionService, never, HttpClient.HttpClient> =>
375
+ Effect.gen(function* () {
376
+ const baseClient = yield* HttpClient.HttpClient;
377
+ const cookies = yield* Ref.make(Cookies.empty);
378
+ const client = HttpClient.withCookiesRef(baseClient, cookies);
379
+
380
+ const infoUrl = joinPath(options.url, "web/session/get_session_info");
381
+
382
+ // The cookie to seed on the next login. Starts as the harvested value;
383
+ // consumed once, and cleared by `invalidate` so a dead cookie is never
384
+ // re-seeded. After that only `renew` can produce another one.
385
+ const nextCookie = yield* Ref.make(Option.some(options.sessionId));
386
+
387
+ // Redacted.value only at the wire boundary (the cookie jar IS the wire
388
+ // buffer). Seeded via `fromSetCookie` so the raw value rides the wire
389
+ // untouched, exactly like a server-set cookie — `Cookies.makeCookie`
390
+ // would percent-encode it (corrupting `+`/`=`/`/`), and an unvalidated
391
+ // `fromSetCookie` would silently truncate at the first `;`.
392
+ const seed = (value: Redacted.Redacted<string>): Effect.Effect<void, CookieLoginError> => {
393
+ const raw = Redacted.value(value);
394
+ if (raw === "" || !cookieOctets.test(raw)) {
395
+ return Effect.fail(
396
+ new OdooAuthenticationError({
397
+ reason: "invalid-credentials",
398
+ message: "The provided session_id is not a valid cookie value (RFC 6265).",
399
+ }),
400
+ );
401
+ }
402
+ return Ref.update(cookies, (jar) =>
403
+ Cookies.merge(jar, Cookies.fromSetCookie(`session_id=${raw}; Path=/`)),
404
+ );
405
+ };
406
+
407
+ const doLogin: Effect.Effect<OdooSessionInfo, CookieLoginError> = Effect.gen(function* () {
408
+ const jar = yield* Ref.get(cookies);
409
+ if (Option.isNone(Cookies.getValue(jar, "session_id"))) {
410
+ const pending = yield* Ref.getAndSet(nextCookie, Option.none());
411
+ if (Option.isSome(pending)) {
412
+ yield* seed(pending.value);
413
+ } else if (options.renew !== undefined) {
414
+ yield* seed(yield* options.renew);
415
+ } else {
416
+ // Fail FAST, without a round trip: this session holds no credentials
417
+ // and no renew hook, so re-login is structurally impossible.
418
+ return yield* Effect.fail(
419
+ new SessionExpiredError({
420
+ message:
421
+ "The injected web session was invalidated and no renew hook was provided; " +
422
+ "obtain a fresh session_id and construct a new session.",
423
+ }),
424
+ );
425
+ }
426
+ }
427
+
428
+ // Validate + hydrate: a dead cookie surfaces here as code 100 →
429
+ // SessionExpiredError via the shared choke point.
430
+ return yield* sessionInfoRoundTrip(
431
+ client,
432
+ infoUrl,
433
+ {},
434
+ "web/session/get_session_info",
435
+ "get_session_info",
436
+ );
437
+ });
438
+
439
+ const flight = yield* SingleFlight.make(doLogin);
440
+
441
+ const invalidate = Effect.gen(function* () {
442
+ yield* Ref.set(cookies, Cookies.empty);
443
+ yield* Ref.set(nextCookie, Option.none());
444
+ yield* flight.invalidate;
445
+ });
446
+
447
+ return {
448
+ login: flight.get,
449
+ invalidate,
450
+ cookies,
451
+ client,
452
+ peek: flight.peek,
453
+ ...makeRawHatches(client, options.url, flight.get),
454
+ };
455
+ });
456
+
457
+ /** Provide `CookieSession` from a harvested cookie. See {@link fromExisting}. */
458
+ export const layerFromExisting = (
459
+ options: ExistingSessionOptions,
460
+ ): Layer.Layer<CookieSession, never, HttpClient.HttpClient> =>
461
+ Layer.effect(CookieSession, fromExisting(options));