@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.
- package/LICENSE +21 -0
- package/README.md +401 -0
- package/dist/client.d.ts +184 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +277 -0
- package/dist/client.js.map +1 -0
- package/dist/combinators/retryOnSessionExpired.d.ts +13 -0
- package/dist/combinators/retryOnSessionExpired.d.ts.map +1 -0
- package/dist/combinators/retryOnSessionExpired.js +15 -0
- package/dist/combinators/retryOnSessionExpired.js.map +1 -0
- package/dist/commands.d.ts +91 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +69 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +60 -0
- package/dist/config.js.map +1 -0
- package/dist/domain.d.ts +30 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/domain.js +70 -0
- package/dist/domain.js.map +1 -0
- package/dist/errors/auth.d.ts +27 -0
- package/dist/errors/auth.d.ts.map +1 -0
- package/dist/errors/auth.js +8 -0
- package/dist/errors/auth.js.map +1 -0
- package/dist/errors/index.d.ts +30 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +13 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/mapFault.d.ts +31 -0
- package/dist/errors/mapFault.d.ts.map +1 -0
- package/dist/errors/mapFault.js +48 -0
- package/dist/errors/mapFault.js.map +1 -0
- package/dist/errors/protocol.d.ts +22 -0
- package/dist/errors/protocol.d.ts.map +1 -0
- package/dist/errors/protocol.js +9 -0
- package/dist/errors/protocol.js.map +1 -0
- package/dist/errors/schema.d.ts +22 -0
- package/dist/errors/schema.d.ts.map +1 -0
- package/dist/errors/schema.js +14 -0
- package/dist/errors/schema.js.map +1 -0
- package/dist/errors/server.d.ts +109 -0
- package/dist/errors/server.d.ts.map +1 -0
- package/dist/errors/server.js +49 -0
- package/dist/errors/server.js.map +1 -0
- package/dist/errors/session.d.ts +16 -0
- package/dist/errors/session.d.ts.map +1 -0
- package/dist/errors/session.js +11 -0
- package/dist/errors/session.js.map +1 -0
- package/dist/errors/transport.d.ts +42 -0
- package/dist/errors/transport.d.ts.map +1 -0
- package/dist/errors/transport.js +49 -0
- package/dist/errors/transport.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/platform.d.ts +12 -0
- package/dist/internal/platform.d.ts.map +1 -0
- package/dist/internal/platform.js +11 -0
- package/dist/internal/platform.js.map +1 -0
- package/dist/internal/singleFlight.d.ts +29 -0
- package/dist/internal/singleFlight.d.ts.map +1 -0
- package/dist/internal/singleFlight.js +41 -0
- package/dist/internal/singleFlight.js.map +1 -0
- package/dist/profiles.d.ts +179 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +191 -0
- package/dist/profiles.js.map +1 -0
- package/dist/protocol/jsonrpc.d.ts +127 -0
- package/dist/protocol/jsonrpc.d.ts.map +1 -0
- package/dist/protocol/jsonrpc.js +117 -0
- package/dist/protocol/jsonrpc.js.map +1 -0
- package/dist/records/index.d.ts +14 -0
- package/dist/records/index.d.ts.map +1 -0
- package/dist/records/index.js +14 -0
- package/dist/records/index.js.map +1 -0
- package/dist/records/recordModel.d.ts +131 -0
- package/dist/records/recordModel.d.ts.map +1 -0
- package/dist/records/recordModel.js +101 -0
- package/dist/records/recordModel.js.map +1 -0
- package/dist/records/related.d.ts +42 -0
- package/dist/records/related.d.ts.map +1 -0
- package/dist/records/related.js +51 -0
- package/dist/records/related.js.map +1 -0
- package/dist/records/relations.d.ts +66 -0
- package/dist/records/relations.d.ts.map +1 -0
- package/dist/records/relations.js +119 -0
- package/dist/records/relations.js.map +1 -0
- package/dist/records/spec.d.ts +44 -0
- package/dist/records/spec.d.ts.map +1 -0
- package/dist/records/spec.js +63 -0
- package/dist/records/spec.js.map +1 -0
- package/dist/records/typed.d.ts +71 -0
- package/dist/records/typed.d.ts.map +1 -0
- package/dist/records/typed.js +65 -0
- package/dist/records/typed.js.map +1 -0
- package/dist/rpc.d.ts +90 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +113 -0
- package/dist/rpc.js.map +1 -0
- package/dist/services/db.d.ts +128 -0
- package/dist/services/db.d.ts.map +1 -0
- package/dist/services/db.js +132 -0
- package/dist/services/db.js.map +1 -0
- package/dist/services/report.d.ts +48 -0
- package/dist/services/report.d.ts.map +1 -0
- package/dist/services/report.js +103 -0
- package/dist/services/report.js.map +1 -0
- package/dist/session/cookie.d.ts +114 -0
- package/dist/session/cookie.d.ts.map +1 -0
- package/dist/session/cookie.js +231 -0
- package/dist/session/cookie.js.map +1 -0
- package/dist/testing/fakeTransport.d.ts +40 -0
- package/dist/testing/fakeTransport.d.ts.map +1 -0
- package/dist/testing/fakeTransport.js +36 -0
- package/dist/testing/fakeTransport.js.map +1 -0
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/transport.d.ts +62 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +9 -0
- package/dist/transport.js.map +1 -0
- package/dist/transports/json2.d.ts +41 -0
- package/dist/transports/json2.d.ts.map +1 -0
- package/dist/transports/json2.js +196 -0
- package/dist/transports/json2.js.map +1 -0
- package/dist/transports/jsonrpc.d.ts +41 -0
- package/dist/transports/jsonrpc.d.ts.map +1 -0
- package/dist/transports/jsonrpc.js +119 -0
- package/dist/transports/jsonrpc.js.map +1 -0
- package/dist/transports/web.d.ts +15 -0
- package/dist/transports/web.d.ts.map +1 -0
- package/dist/transports/web.js +62 -0
- package/dist/transports/web.js.map +1 -0
- package/dist/version-live.d.ts +41 -0
- package/dist/version-live.d.ts.map +1 -0
- package/dist/version-live.js +26 -0
- package/dist/version-live.js.map +1 -0
- package/dist/version.d.ts +78 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +46 -0
- package/dist/version.js.map +1 -0
- package/docs/protocol-verification.md +43 -0
- package/package.json +71 -0
- package/src/client.ts +731 -0
- package/src/combinators/retryOnSessionExpired.ts +25 -0
- package/src/commands.ts +112 -0
- package/src/config.ts +110 -0
- package/src/domain.ts +91 -0
- package/src/errors/auth.ts +28 -0
- package/src/errors/index.ts +50 -0
- package/src/errors/mapFault.ts +92 -0
- package/src/errors/protocol.ts +19 -0
- package/src/errors/schema.ts +18 -0
- package/src/errors/server.ts +79 -0
- package/src/errors/session.ts +12 -0
- package/src/errors/transport.ts +74 -0
- package/src/index.ts +152 -0
- package/src/internal/platform.ts +22 -0
- package/src/internal/singleFlight.ts +80 -0
- package/src/profiles.ts +386 -0
- package/src/protocol/jsonrpc.ts +162 -0
- package/src/records/index.ts +49 -0
- package/src/records/recordModel.ts +211 -0
- package/src/records/related.ts +80 -0
- package/src/records/relations.ts +192 -0
- package/src/records/spec.ts +96 -0
- package/src/records/typed.ts +180 -0
- package/src/rpc.ts +219 -0
- package/src/services/db.ts +282 -0
- package/src/services/report.ts +165 -0
- package/src/session/cookie.ts +461 -0
- package/src/testing/fakeTransport.ts +84 -0
- package/src/testing/index.ts +9 -0
- package/src/transport.ts +72 -0
- package/src/transports/json2.ts +303 -0
- package/src/transports/jsonrpc.ts +218 -0
- package/src/transports/web.ts +96 -0
- package/src/version-live.ts +67 -0
- 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));
|