@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
package/src/profiles.ts
ADDED
|
@@ -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";
|