@manajemin-dev-cli/sdk 0.0.0-stage → 0.1.1
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 +201 -0
- package/NOTICE +4 -0
- package/README.md +69 -2
- package/client.d.ts +248 -0
- package/client.js +225 -0
- package/delivery.d.ts +96 -0
- package/delivery.js +41 -0
- package/generated/api.d.ts +123 -0
- package/generated/api.js +26 -0
- package/index.d.ts +8 -0
- package/index.js +4 -0
- package/package.json +25 -4
- package/signature.d.ts +33 -0
- package/signature.js +55 -0
- package/tenant.d.ts +44 -0
- package/tenant.js +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/NOTICE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @manajemin-dev-cli/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect an app to the Manajemin platform: verify what the platform sends you, redeem sign-in codes, acknowledge installs
|
|
4
|
+
and removals, report usage, and keep every database query inside one organisation's workspace. No runtime dependencies.
|
|
5
|
+
Node 20 or later.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @manajemin-dev-cli/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The rules it implements are the published contract in `doc/integration/` (`contract.json`, `openapi.yaml`,
|
|
12
|
+
`signature-vectors.json`), not this code. Its tests run every published signature vector.
|
|
13
|
+
|
|
14
|
+
## Receive a delivery
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { receive } from "@manajemin-dev-cli/sdk";
|
|
18
|
+
|
|
19
|
+
const result = receive({ secret: process.env.MANAJEMIN_WEBHOOK_SECRET!, headers: request.headers, rawBody });
|
|
20
|
+
if (!result.ok) return new Response(result.reason, { status: result.status }); // 401 bad signature, 400 malformed
|
|
21
|
+
const { id, event, body } = result.delivery;
|
|
22
|
+
// Deduplicate on `id`; answer 2xx only after the event is stored.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Pass the **raw** body, never a re-serialised object. While you replace the webhook secret, pass both:
|
|
26
|
+
`secret: [newSecret, oldSecret]`. For 24 hours after a secret changes the platform signs with both.
|
|
27
|
+
|
|
28
|
+
## Call the platform
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { ManajeminClient } from "@manajemin-dev-cli/sdk";
|
|
32
|
+
|
|
33
|
+
const manajemin = new ManajeminClient({ appKey: process.env.MANAJEMIN_APP_KEY!, appId: "my-tasks", installTokens });
|
|
34
|
+
await manajemin.ack(entityId); // after the workspace exists: activates the install
|
|
35
|
+
await manajemin.uninstallAck(entityId); // after the workspace's data is deleted
|
|
36
|
+
await manajemin.ackRemoval({ entityId, memberId, outboxId }); // after the member's data is reassigned or deleted
|
|
37
|
+
const { claims, redirect } = await manajemin.exchange(code, { entityId }); // SSO landing: ?code=…&entity=…
|
|
38
|
+
await manajemin.reportUsage([{ metric: "tasks_created", qty: 1, entity_id: entityId, dedupe_key: taskId }]);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`installTokens` is where you keep per-install tokens if the platform team switches your app to them: `get`, `set` and
|
|
42
|
+
`delete` by entity id. The first `ack` of an install returns the token and the client stores it; every later call for that
|
|
43
|
+
entity sends it. Without per-install tokens, leave it out.
|
|
44
|
+
|
|
45
|
+
Errors throw `ManajeminApiError` with the platform's public message, the HTTP status and the request id.
|
|
46
|
+
|
|
47
|
+
## One workspace per organisation (Nile)
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { guardTenantTables, withTenant } from "@manajemin-dev-cli/sdk";
|
|
51
|
+
|
|
52
|
+
// `workspaces` maps the entity id from an event to the live Nile tenant id (see the reference app).
|
|
53
|
+
const tenantId = await liveTenantOf(entityId);
|
|
54
|
+
const tasks = await withTenant(pool, tenantId, (db) => db.query("select * from tasks where tenant_id = $1", [tenantId]));
|
|
55
|
+
|
|
56
|
+
// Everything that is not a tenant table goes through a guarded handle, so a forgotten withTenant fails loudly.
|
|
57
|
+
const shared = guardTenantTables(pool, ["tasks", "sessions"]);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Do **not** use the entity id as the Nile tenant id. Nile never frees a deleted tenant's id, so an organisation that uninstalls
|
|
61
|
+
your app and installs it again could not get its workspace back. Give each install a fresh tenant id and keep the mapping in
|
|
62
|
+
your own table; `examples/reference-app` shows it, including erasing and reinstalling.
|
|
63
|
+
|
|
64
|
+
`withTenant` sets the tenant for one transaction only, so a pooled connection never carries one organisation's context into the
|
|
65
|
+
next request. Run every tenant query through it. Nile returns every tenant's rows to a query that runs with no tenant at all, so
|
|
66
|
+
`guardTenantTables` refuses a statement that names a tenant table outside `withTenant`.
|
|
67
|
+
|
|
68
|
+
## Tests
|
|
69
|
+
|
|
70
|
+
`npm test` in this folder (Node's built-in test runner).
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app -> platform calls of `openapi.yaml` (API version 2026-11-01). Every call carries the app key; a call that names
|
|
3
|
+
* an entity also carries that install's token when the app is on per-install tokens. Errors throw `ManajeminApiError`
|
|
4
|
+
* with the platform's public message, never a secret.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_BASE_URL = "https://www.manajemin.com";
|
|
7
|
+
/** Where install tokens live: one per entity, returned once by the install's first ack. Persist it with the workspace. */
|
|
8
|
+
export interface InstallTokenStore {
|
|
9
|
+
get(entityId: string): Promise<string | null | undefined> | string | null | undefined;
|
|
10
|
+
set(entityId: string, token: string): Promise<void> | void;
|
|
11
|
+
delete(entityId: string): Promise<void> | void;
|
|
12
|
+
}
|
|
13
|
+
export interface ClientOptions {
|
|
14
|
+
/** `<key_prefix>.<secret>`, shown once in the developer portal. Keep it in a secret store, never in source control. */
|
|
15
|
+
appKey: string;
|
|
16
|
+
appId: string;
|
|
17
|
+
/** Defaults to https://www.manajemin.com (the apex redirects, and many clients drop custom headers on a redirect). */
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
installTokens?: InstallTokenStore;
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
/** Per request, milliseconds. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class ManajeminApiError extends Error {
|
|
25
|
+
readonly status: number;
|
|
26
|
+
readonly code: string | null;
|
|
27
|
+
readonly requestId: string | null;
|
|
28
|
+
constructor(status: number, message: string, code: string | null, requestId: string | null);
|
|
29
|
+
}
|
|
30
|
+
export interface Claims {
|
|
31
|
+
sub: string;
|
|
32
|
+
email: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
tenant_id: string;
|
|
35
|
+
entity_id: string;
|
|
36
|
+
product: string;
|
|
37
|
+
}
|
|
38
|
+
export interface UsageEvent {
|
|
39
|
+
metric: string;
|
|
40
|
+
/** A delta, not a level. */
|
|
41
|
+
qty: number;
|
|
42
|
+
entity_id: string;
|
|
43
|
+
/** Makes a retried report a no-op. Derive it from your own event id. */
|
|
44
|
+
dedupe_key: string;
|
|
45
|
+
}
|
|
46
|
+
export interface Member {
|
|
47
|
+
member_id: string;
|
|
48
|
+
user_id: string | null;
|
|
49
|
+
name: string;
|
|
50
|
+
email: string;
|
|
51
|
+
role: string;
|
|
52
|
+
}
|
|
53
|
+
/** An event as delivered to a subscriber (the webhook body) or returned by `pullEvents`. */
|
|
54
|
+
export interface AppEvent {
|
|
55
|
+
event_id: string;
|
|
56
|
+
/** `<publisher app id>.<noun>.<verb>.v<N>`. */
|
|
57
|
+
event: string;
|
|
58
|
+
/** The publishing app's id. */
|
|
59
|
+
source: string;
|
|
60
|
+
entity_id: string;
|
|
61
|
+
time: string;
|
|
62
|
+
trace_id: string;
|
|
63
|
+
/** Only the fields the publisher declared; scalar values. A thin fact: fetch the record with your own permission if you need more. */
|
|
64
|
+
data: Record<string, string | number | boolean | null>;
|
|
65
|
+
}
|
|
66
|
+
export interface StoredFile {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
contentType: string;
|
|
70
|
+
sizeBytes: number;
|
|
71
|
+
createdAt: string;
|
|
72
|
+
}
|
|
73
|
+
export declare class ManajeminClient {
|
|
74
|
+
readonly appId: string;
|
|
75
|
+
private readonly appKey;
|
|
76
|
+
private readonly baseUrl;
|
|
77
|
+
private readonly tokens;
|
|
78
|
+
private readonly fetchImpl;
|
|
79
|
+
private readonly timeoutMs;
|
|
80
|
+
constructor(options: ClientOptions);
|
|
81
|
+
/** Activates an install. Call it after the workspace for the entity exists. Stores the install token if one comes back. */
|
|
82
|
+
ack(entityId: string): Promise<{
|
|
83
|
+
installToken: string | null;
|
|
84
|
+
}>;
|
|
85
|
+
/** Call only after the workspace's data is deleted. The install token ends with it. */
|
|
86
|
+
uninstallAck(entityId: string): Promise<void>;
|
|
87
|
+
/** Call after the member's data is reassigned or deleted. `outboxId` is the event body's `outbox_id`. */
|
|
88
|
+
ackRemoval(input: {
|
|
89
|
+
entityId: string;
|
|
90
|
+
memberId: string;
|
|
91
|
+
outboxId: string;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
completed: boolean;
|
|
94
|
+
}>;
|
|
95
|
+
/**
|
|
96
|
+
* Redeems the one-time SSO code from the landing URL. Pass the landing URL's `entity`: an app on per-install tokens
|
|
97
|
+
* must, and the token is checked before the code is spent.
|
|
98
|
+
*/
|
|
99
|
+
exchange(code: string, options?: {
|
|
100
|
+
entityId?: string;
|
|
101
|
+
}): Promise<{
|
|
102
|
+
claims: Claims;
|
|
103
|
+
redirect: string | null;
|
|
104
|
+
}>;
|
|
105
|
+
/** The entity's current plan, limits and features: the repair path after a missed event. */
|
|
106
|
+
snapshot(entityId: string): Promise<Record<string, unknown>>;
|
|
107
|
+
/** Reports usage deltas. All events of one call must name the same entity (one install token per call). */
|
|
108
|
+
reportUsage(events: UsageEvent[]): Promise<{
|
|
109
|
+
accepted: number;
|
|
110
|
+
rejected: number;
|
|
111
|
+
errors: string[];
|
|
112
|
+
}>;
|
|
113
|
+
/**
|
|
114
|
+
* Creates a guest grant: someone outside the organisation who may read (or comment on) what this app shows for the entity. The app
|
|
115
|
+
* must support guest access. Returns whether it was created; false when a grant already exists for that entity and email.
|
|
116
|
+
*/
|
|
117
|
+
createGuestGrant(input: {
|
|
118
|
+
entityId: string;
|
|
119
|
+
email: string;
|
|
120
|
+
scope?: "read_only" | "comment";
|
|
121
|
+
}): Promise<{
|
|
122
|
+
created: boolean;
|
|
123
|
+
}>;
|
|
124
|
+
/** Scope `members:read`. */
|
|
125
|
+
members(entityId: string): Promise<{
|
|
126
|
+
members: Member[];
|
|
127
|
+
truncated: boolean;
|
|
128
|
+
}>;
|
|
129
|
+
/** Scope `audit:write`. Stored as `app.activity_recorded` with your event name inside. */
|
|
130
|
+
audit(input: {
|
|
131
|
+
entityId: string;
|
|
132
|
+
event: string;
|
|
133
|
+
summary?: string;
|
|
134
|
+
targetType?: string;
|
|
135
|
+
targetId?: string;
|
|
136
|
+
memberId?: string;
|
|
137
|
+
details?: Record<string, string | number | boolean | null>;
|
|
138
|
+
}): Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* Scope `telemetry:write`. Reports which of your features were used on a workspace (up to 100 per call). Counts only: a feature name
|
|
141
|
+
* and, if you like, the member who used it. The platform shows it as adoption under the module `app:<your app id>`.
|
|
142
|
+
*/
|
|
143
|
+
telemetry(entityId: string, events: Array<{
|
|
144
|
+
feature: string;
|
|
145
|
+
memberId?: string;
|
|
146
|
+
occurredAt?: Date;
|
|
147
|
+
}>): Promise<{
|
|
148
|
+
recorded: number;
|
|
149
|
+
}>;
|
|
150
|
+
/**
|
|
151
|
+
* Scope `files:store`. Keeps a file (at most 20 MB) for a workspace in the platform's private storage, counted against the
|
|
152
|
+
* organisation's storage quota. The bytes go straight to storage on a short-lived link that never sees your app key; the platform
|
|
153
|
+
* then checks the size before it counts the file.
|
|
154
|
+
*/
|
|
155
|
+
storeFile(input: {
|
|
156
|
+
entityId: string;
|
|
157
|
+
name: string;
|
|
158
|
+
contentType: string;
|
|
159
|
+
data: Uint8Array;
|
|
160
|
+
}): Promise<StoredFile>;
|
|
161
|
+
/** Scope `files:store`. The app's finished files on a workspace, newest first (up to 100). */
|
|
162
|
+
listFiles(entityId: string): Promise<StoredFile[]>;
|
|
163
|
+
/** Scope `files:store`. A fresh link, valid for five minutes, that downloads the file. */
|
|
164
|
+
fileDownloadUrl(entityId: string, fileId: string): Promise<{
|
|
165
|
+
file: StoredFile;
|
|
166
|
+
url: string;
|
|
167
|
+
}>;
|
|
168
|
+
/** Scope `files:store`. Removes the file and gives its size back to the quota. */
|
|
169
|
+
deleteFile(entityId: string, fileId: string): Promise<void>;
|
|
170
|
+
/** Scope `notify:send`. */
|
|
171
|
+
notify(input: {
|
|
172
|
+
entityId: string;
|
|
173
|
+
memberId: string;
|
|
174
|
+
subject: string;
|
|
175
|
+
message: string;
|
|
176
|
+
actionLabel?: string;
|
|
177
|
+
actionUrl?: string;
|
|
178
|
+
}): Promise<void>;
|
|
179
|
+
/** Scope `ai:invoke`. */
|
|
180
|
+
ai(input: {
|
|
181
|
+
entityId: string;
|
|
182
|
+
memberId: string;
|
|
183
|
+
messages: {
|
|
184
|
+
role: "user" | "assistant";
|
|
185
|
+
content: string;
|
|
186
|
+
}[];
|
|
187
|
+
feature?: string;
|
|
188
|
+
system?: string;
|
|
189
|
+
maxTokens?: number;
|
|
190
|
+
}): Promise<{
|
|
191
|
+
text: string;
|
|
192
|
+
feature: string;
|
|
193
|
+
usage: Record<string, unknown>;
|
|
194
|
+
}>;
|
|
195
|
+
/**
|
|
196
|
+
* After `subject.erasure_requested`: say you deleted the person's data on this entity. Call it only once the data is gone. Repeating
|
|
197
|
+
* it is harmless; `completed` is true on the call that finishes the request (every installed app has now confirmed).
|
|
198
|
+
*/
|
|
199
|
+
ackSubjectErasure(input: {
|
|
200
|
+
requestId: string;
|
|
201
|
+
entityId: string;
|
|
202
|
+
}): Promise<{
|
|
203
|
+
completed: boolean;
|
|
204
|
+
}>;
|
|
205
|
+
/**
|
|
206
|
+
* After `app.export_requested`: say where your export of the entity's data is. The link must be https, on your own host, free of
|
|
207
|
+
* credentials, and stop working within 7 days; the platform shows it to the tenant admin and never holds the data itself.
|
|
208
|
+
*/
|
|
209
|
+
exportReady(input: {
|
|
210
|
+
exportId: string;
|
|
211
|
+
entityId: string;
|
|
212
|
+
url: string;
|
|
213
|
+
expiresAt: Date;
|
|
214
|
+
bytes?: number;
|
|
215
|
+
}): Promise<void>;
|
|
216
|
+
/**
|
|
217
|
+
* Scope `events:publish`. Hands an event your manifest declares to the other apps on this workspace; you are never told who
|
|
218
|
+
* receives it. Delivery is at least once with a stable `event_id`, so pass an `idempotencyKey` derived from your own record
|
|
219
|
+
* (a retried publish then returns the first event). Pass `traceId` (32 hex) to continue a trace that began elsewhere.
|
|
220
|
+
*/
|
|
221
|
+
publishEvent(input: {
|
|
222
|
+
entityId: string;
|
|
223
|
+
type: string;
|
|
224
|
+
data: AppEvent["data"];
|
|
225
|
+
idempotencyKey?: string;
|
|
226
|
+
occurredAt?: Date;
|
|
227
|
+
}, options?: {
|
|
228
|
+
traceId?: string;
|
|
229
|
+
}): Promise<{
|
|
230
|
+
eventId: string;
|
|
231
|
+
traceId: string;
|
|
232
|
+
duplicate: boolean;
|
|
233
|
+
}>;
|
|
234
|
+
/**
|
|
235
|
+
* The events your manifest subscribed to, for an entity, oldest first: the catch-up path for an app that was down or cannot
|
|
236
|
+
* keep up with pushes. Send the returned `next` as `after` next time. Events are kept 7 days.
|
|
237
|
+
*/
|
|
238
|
+
pullEvents(entityId: string, options?: {
|
|
239
|
+
after?: number;
|
|
240
|
+
limit?: number;
|
|
241
|
+
}): Promise<{
|
|
242
|
+
events: AppEvent[];
|
|
243
|
+
next: number;
|
|
244
|
+
}>;
|
|
245
|
+
private call;
|
|
246
|
+
}
|
|
247
|
+
/** Only a same-site relative path; anything else is dropped, so a code cannot become an open redirect in your app. */
|
|
248
|
+
export declare function safeRedirect(value: string | null | undefined): string | null;
|
package/client.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app -> platform calls of `openapi.yaml` (API version 2026-11-01). Every call carries the app key; a call that names
|
|
3
|
+
* an entity also carries that install's token when the app is on per-install tokens. Errors throw `ManajeminApiError`
|
|
4
|
+
* with the platform's public message, never a secret.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_BASE_URL = "https://www.manajemin.com";
|
|
7
|
+
export class ManajeminApiError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
code;
|
|
10
|
+
requestId;
|
|
11
|
+
constructor(status, message, code, requestId) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ManajeminApiError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.requestId = requestId;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const toStoredFile = (f) => ({ id: f.id, name: f.name, contentType: f.content_type, sizeBytes: f.size_bytes, createdAt: f.created_at });
|
|
20
|
+
export class ManajeminClient {
|
|
21
|
+
appId;
|
|
22
|
+
appKey;
|
|
23
|
+
baseUrl;
|
|
24
|
+
tokens;
|
|
25
|
+
fetchImpl;
|
|
26
|
+
timeoutMs;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
if (!/^[^.\s]+\.[^.\s]+$/.test(options.appKey))
|
|
29
|
+
throw new Error("appKey must be <key_prefix>.<secret>");
|
|
30
|
+
this.appKey = options.appKey;
|
|
31
|
+
this.appId = options.appId;
|
|
32
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
33
|
+
this.tokens = options.installTokens;
|
|
34
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
35
|
+
this.timeoutMs = options.timeoutMs ?? 10_000;
|
|
36
|
+
}
|
|
37
|
+
/** Activates an install. Call it after the workspace for the entity exists. Stores the install token if one comes back. */
|
|
38
|
+
async ack(entityId) {
|
|
39
|
+
const body = await this.call("POST", "/api/integration/ack", { entity_id: entityId }, entityId);
|
|
40
|
+
if (body.install_token)
|
|
41
|
+
await this.tokens?.set(entityId, body.install_token);
|
|
42
|
+
return { installToken: body.install_token ?? null };
|
|
43
|
+
}
|
|
44
|
+
/** Call only after the workspace's data is deleted. The install token ends with it. */
|
|
45
|
+
async uninstallAck(entityId) {
|
|
46
|
+
await this.call("POST", "/api/integration/uninstall-ack", { entity_id: entityId }, entityId);
|
|
47
|
+
await this.tokens?.delete(entityId);
|
|
48
|
+
}
|
|
49
|
+
/** Call after the member's data is reassigned or deleted. `outboxId` is the event body's `outbox_id`. */
|
|
50
|
+
async ackRemoval(input) {
|
|
51
|
+
const body = await this.call("POST", "/api/integration/ack-removal", { member_id: input.memberId, outbox_id: input.outboxId }, input.entityId);
|
|
52
|
+
return { completed: body.completed === true };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Redeems the one-time SSO code from the landing URL. Pass the landing URL's `entity`: an app on per-install tokens
|
|
56
|
+
* must, and the token is checked before the code is spent.
|
|
57
|
+
*/
|
|
58
|
+
async exchange(code, options = {}) {
|
|
59
|
+
const body = await this.call("POST", "/api/integration/exchange", (options.entityId ? { code, entity_id: options.entityId } : { code }), options.entityId ?? null);
|
|
60
|
+
if (options.entityId && body.claims.entity_id !== options.entityId) {
|
|
61
|
+
throw new ManajeminApiError(401, "the code belongs to another entity", null, null);
|
|
62
|
+
}
|
|
63
|
+
return { claims: body.claims, redirect: safeRedirect(body.redirect) };
|
|
64
|
+
}
|
|
65
|
+
/** The entity's current plan, limits and features: the repair path after a missed event. */
|
|
66
|
+
async snapshot(entityId) {
|
|
67
|
+
const query = new URLSearchParams({ app_id: this.appId, entity_id: entityId });
|
|
68
|
+
return this.call("GET", `/api/integration/snapshot?${query}`, undefined, entityId);
|
|
69
|
+
}
|
|
70
|
+
/** Reports usage deltas. All events of one call must name the same entity (one install token per call). */
|
|
71
|
+
async reportUsage(events) {
|
|
72
|
+
const entities = new Set(events.map((e) => e.entity_id));
|
|
73
|
+
if (events.length === 0 || entities.size !== 1)
|
|
74
|
+
throw new Error("reportUsage needs 1 to 500 events for one entity");
|
|
75
|
+
const body = await this.call("POST", "/api/integration/usage", { events }, events[0].entity_id);
|
|
76
|
+
return { accepted: body.accepted ?? 0, rejected: body.rejected ?? 0, errors: body.errors ?? [] };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Creates a guest grant: someone outside the organisation who may read (or comment on) what this app shows for the entity. The app
|
|
80
|
+
* must support guest access. Returns whether it was created; false when a grant already exists for that entity and email.
|
|
81
|
+
*/
|
|
82
|
+
async createGuestGrant(input) {
|
|
83
|
+
try {
|
|
84
|
+
await this.call("POST", "/api/integration/guest-grants", { entityId: input.entityId, email: input.email, scope: input.scope }, input.entityId);
|
|
85
|
+
return { created: true };
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof ManajeminApiError && err.status === 409)
|
|
89
|
+
return { created: false };
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Scope `members:read`. */
|
|
94
|
+
async members(entityId) {
|
|
95
|
+
return this.call("GET", `/api/integration/members?${new URLSearchParams({ entity_id: entityId })}`, undefined, entityId);
|
|
96
|
+
}
|
|
97
|
+
/** Scope `audit:write`. Stored as `app.activity_recorded` with your event name inside. */
|
|
98
|
+
async audit(input) {
|
|
99
|
+
await this.call("POST", "/api/integration/audit", {
|
|
100
|
+
entity_id: input.entityId,
|
|
101
|
+
event: input.event,
|
|
102
|
+
summary: input.summary,
|
|
103
|
+
target_type: input.targetType,
|
|
104
|
+
target_id: input.targetId,
|
|
105
|
+
member_id: input.memberId,
|
|
106
|
+
details: input.details,
|
|
107
|
+
}, input.entityId);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Scope `telemetry:write`. Reports which of your features were used on a workspace (up to 100 per call). Counts only: a feature name
|
|
111
|
+
* and, if you like, the member who used it. The platform shows it as adoption under the module `app:<your app id>`.
|
|
112
|
+
*/
|
|
113
|
+
async telemetry(entityId, events) {
|
|
114
|
+
return this.call("POST", "/api/integration/telemetry", { entity_id: entityId, events: events.map((e) => ({ feature: e.feature, member_id: e.memberId, occurred_at: e.occurredAt?.toISOString() })) }, entityId);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Scope `files:store`. Keeps a file (at most 20 MB) for a workspace in the platform's private storage, counted against the
|
|
118
|
+
* organisation's storage quota. The bytes go straight to storage on a short-lived link that never sees your app key; the platform
|
|
119
|
+
* then checks the size before it counts the file.
|
|
120
|
+
*/
|
|
121
|
+
async storeFile(input) {
|
|
122
|
+
const started = await this.call("POST", "/api/integration/files", { entity_id: input.entityId, name: input.name, content_type: input.contentType, size_bytes: input.data.byteLength }, input.entityId);
|
|
123
|
+
// Not through `call`: this goes to the storage host, which must never receive the app key or an install token.
|
|
124
|
+
const put = await this.fetchImpl(started.upload_url, { method: "PUT", headers: started.upload_headers, body: input.data, redirect: "error", signal: AbortSignal.timeout(this.timeoutMs * 6) });
|
|
125
|
+
if (!put.ok)
|
|
126
|
+
throw new ManajeminApiError(put.status, "the storage host refused the upload", null, null);
|
|
127
|
+
const done = await this.call("POST", `/api/integration/files/${started.file_id}/complete`, { entity_id: input.entityId }, input.entityId);
|
|
128
|
+
return toStoredFile(done.file);
|
|
129
|
+
}
|
|
130
|
+
/** Scope `files:store`. The app's finished files on a workspace, newest first (up to 100). */
|
|
131
|
+
async listFiles(entityId) {
|
|
132
|
+
const query = new URLSearchParams({ entity_id: entityId });
|
|
133
|
+
const body = await this.call("GET", `/api/integration/files?${query}`, undefined, entityId);
|
|
134
|
+
return body.files.map(toStoredFile);
|
|
135
|
+
}
|
|
136
|
+
/** Scope `files:store`. A fresh link, valid for five minutes, that downloads the file. */
|
|
137
|
+
async fileDownloadUrl(entityId, fileId) {
|
|
138
|
+
const body = await this.call("GET", `/api/integration/files/${encodeURIComponent(fileId)}?${new URLSearchParams({ entity_id: entityId })}`, undefined, entityId);
|
|
139
|
+
return { file: toStoredFile(body.file), url: body.download_url };
|
|
140
|
+
}
|
|
141
|
+
/** Scope `files:store`. Removes the file and gives its size back to the quota. */
|
|
142
|
+
async deleteFile(entityId, fileId) {
|
|
143
|
+
await this.call("DELETE", `/api/integration/files/${encodeURIComponent(fileId)}?${new URLSearchParams({ entity_id: entityId })}`, undefined, entityId);
|
|
144
|
+
}
|
|
145
|
+
/** Scope `notify:send`. */
|
|
146
|
+
async notify(input) {
|
|
147
|
+
await this.call("POST", "/api/integration/notify", { entity_id: input.entityId, member_id: input.memberId, subject: input.subject, message: input.message, action_label: input.actionLabel, action_url: input.actionUrl }, input.entityId);
|
|
148
|
+
}
|
|
149
|
+
/** Scope `ai:invoke`. */
|
|
150
|
+
async ai(input) {
|
|
151
|
+
return this.call("POST", "/api/integration/ai", { entity_id: input.entityId, member_id: input.memberId, messages: input.messages, feature: input.feature, system: input.system, max_tokens: input.maxTokens }, input.entityId);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* After `subject.erasure_requested`: say you deleted the person's data on this entity. Call it only once the data is gone. Repeating
|
|
155
|
+
* it is harmless; `completed` is true on the call that finishes the request (every installed app has now confirmed).
|
|
156
|
+
*/
|
|
157
|
+
async ackSubjectErasure(input) {
|
|
158
|
+
const body = await this.call("POST", "/api/integration/subject-erasure-ack", { request_id: input.requestId, entity_id: input.entityId }, input.entityId);
|
|
159
|
+
return { completed: body.completed === true };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* After `app.export_requested`: say where your export of the entity's data is. The link must be https, on your own host, free of
|
|
163
|
+
* credentials, and stop working within 7 days; the platform shows it to the tenant admin and never holds the data itself.
|
|
164
|
+
*/
|
|
165
|
+
async exportReady(input) {
|
|
166
|
+
await this.call("POST", "/api/integration/export-ready", { export_id: input.exportId, entity_id: input.entityId, url: input.url, expires_at: input.expiresAt.toISOString(), bytes: input.bytes }, input.entityId);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Scope `events:publish`. Hands an event your manifest declares to the other apps on this workspace; you are never told who
|
|
170
|
+
* receives it. Delivery is at least once with a stable `event_id`, so pass an `idempotencyKey` derived from your own record
|
|
171
|
+
* (a retried publish then returns the first event). Pass `traceId` (32 hex) to continue a trace that began elsewhere.
|
|
172
|
+
*/
|
|
173
|
+
async publishEvent(input, options = {}) {
|
|
174
|
+
const body = await this.call("POST", "/api/integration/events", { entity_id: input.entityId, type: input.type, data: input.data, idempotency_key: input.idempotencyKey, occurred_at: input.occurredAt?.toISOString() }, input.entityId, options.traceId ? { traceparent: `00-${options.traceId}-${randomSpanId()}-01` } : undefined);
|
|
175
|
+
return { eventId: body.event_id, traceId: body.trace_id, duplicate: body.duplicate };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The events your manifest subscribed to, for an entity, oldest first: the catch-up path for an app that was down or cannot
|
|
179
|
+
* keep up with pushes. Send the returned `next` as `after` next time. Events are kept 7 days.
|
|
180
|
+
*/
|
|
181
|
+
async pullEvents(entityId, options = {}) {
|
|
182
|
+
const query = new URLSearchParams({ entity_id: entityId, after: String(options.after ?? 0), limit: String(options.limit ?? 50) });
|
|
183
|
+
return this.call("GET", `/api/integration/events?${query}`, undefined, entityId);
|
|
184
|
+
}
|
|
185
|
+
async call(method, path, body, entityId, extraHeaders) {
|
|
186
|
+
const headers = { "x-manajemin-key": this.appKey, accept: "application/json", ...extraHeaders };
|
|
187
|
+
if (body !== undefined)
|
|
188
|
+
headers["content-type"] = "application/json";
|
|
189
|
+
if (entityId && this.tokens) {
|
|
190
|
+
const token = await this.tokens.get(entityId);
|
|
191
|
+
if (token)
|
|
192
|
+
headers["x-manajemin-install-token"] = token;
|
|
193
|
+
}
|
|
194
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
195
|
+
method,
|
|
196
|
+
headers,
|
|
197
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
198
|
+
// A redirect would carry the key to wherever it points.
|
|
199
|
+
redirect: "error",
|
|
200
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
201
|
+
});
|
|
202
|
+
const text = await response.text();
|
|
203
|
+
let parsed = null;
|
|
204
|
+
try {
|
|
205
|
+
parsed = text ? JSON.parse(text) : null;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
parsed = null;
|
|
209
|
+
}
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
const error = (parsed ?? {});
|
|
212
|
+
throw new ManajeminApiError(response.status, error.error ?? `HTTP ${response.status}`, error.code ?? null, error.requestId ?? null);
|
|
213
|
+
}
|
|
214
|
+
return (parsed ?? {});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Only a same-site relative path; anything else is dropped, so a code cannot become an open redirect in your app. */
|
|
218
|
+
export function safeRedirect(value) {
|
|
219
|
+
return value && /^\/(?![/\\])/.test(value) ? value : null;
|
|
220
|
+
}
|
|
221
|
+
function randomSpanId() {
|
|
222
|
+
const bytes = new Uint8Array(8);
|
|
223
|
+
crypto.getRandomValues(bytes);
|
|
224
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
225
|
+
}
|
package/delivery.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type HeaderSource, type VerifyFailure, type VerifyInput } from "./signature.ts";
|
|
2
|
+
/** The body shapes of `contract.json`, API version 2026-11-01. Unknown fields are kept: new ones arrive within a version. */
|
|
3
|
+
export interface MemberPayload {
|
|
4
|
+
member_id: string | null;
|
|
5
|
+
user_id: string | null;
|
|
6
|
+
email: string | null;
|
|
7
|
+
role?: string | null;
|
|
8
|
+
transfer_to_member_id?: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface EventBody {
|
|
11
|
+
api_version: string;
|
|
12
|
+
event: string;
|
|
13
|
+
/** The platform's id for the event; `ack-removal` needs it. */
|
|
14
|
+
outbox_id?: string;
|
|
15
|
+
tenant_id: string | null;
|
|
16
|
+
entity_id?: string;
|
|
17
|
+
/** For member and guest events: who the event is about (contract.json lists the fields per event). */
|
|
18
|
+
payload?: Record<string, unknown>;
|
|
19
|
+
/** The entity's current plan, limits and features, on entity events. */
|
|
20
|
+
snapshot?: Record<string, unknown>;
|
|
21
|
+
[field: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
/** The body of an event another app published (plan section 18): `event` is its type, `source` the publishing app. */
|
|
24
|
+
export interface AppEventBody extends EventBody {
|
|
25
|
+
event_id: string;
|
|
26
|
+
source: string;
|
|
27
|
+
time: string;
|
|
28
|
+
trace_id: string;
|
|
29
|
+
/** Only the fields the publisher declared, scalar values. */
|
|
30
|
+
data: Record<string, string | number | boolean | null>;
|
|
31
|
+
}
|
|
32
|
+
/** `subject.erasure_requested` (plan T6): delete this person's data on `entity_id`, then `ackSubjectErasure`. */
|
|
33
|
+
export interface SubjectErasureBody extends EventBody {
|
|
34
|
+
payload: {
|
|
35
|
+
request_id: string;
|
|
36
|
+
email: string | null;
|
|
37
|
+
user_id: string | null;
|
|
38
|
+
member_id: string | null;
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** `app.export_requested` (plan T6): build your export of `entity_id`'s data, then `exportReady`. */
|
|
43
|
+
export interface ExportRequestBody extends EventBody {
|
|
44
|
+
payload: {
|
|
45
|
+
export_id: string;
|
|
46
|
+
reason: "tenant_request" | "tenant_exit";
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export interface ToolCallBody {
|
|
50
|
+
api_version: string;
|
|
51
|
+
tool: string;
|
|
52
|
+
call_id: string;
|
|
53
|
+
arguments: Record<string, unknown>;
|
|
54
|
+
confirmed?: true;
|
|
55
|
+
context: {
|
|
56
|
+
tenant_id: string;
|
|
57
|
+
entity_id: string;
|
|
58
|
+
member: {
|
|
59
|
+
id: string;
|
|
60
|
+
user_id: string | null;
|
|
61
|
+
name: string;
|
|
62
|
+
email: string;
|
|
63
|
+
role: string;
|
|
64
|
+
};
|
|
65
|
+
initiated_by: "jemi";
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export interface Delivery<B = EventBody> {
|
|
69
|
+
/** X-Manajemin-Delivery: the idempotency key. The same delivery can arrive more than once. */
|
|
70
|
+
id: string;
|
|
71
|
+
event: string;
|
|
72
|
+
tenantId: string | null;
|
|
73
|
+
entityId: string | null;
|
|
74
|
+
apiVersion: string | null;
|
|
75
|
+
/** The W3C trace id from `traceparent` when the delivery belongs to a traced flow (an app event): log it with everything you do for it. */
|
|
76
|
+
traceId: string | null;
|
|
77
|
+
body: B;
|
|
78
|
+
}
|
|
79
|
+
export type ReceiveResult<B> = {
|
|
80
|
+
ok: true;
|
|
81
|
+
delivery: Delivery<B>;
|
|
82
|
+
}
|
|
83
|
+
/** `status` is what to answer: 401 for a signature failure, 400 for a body that is not the contract's. */
|
|
84
|
+
| {
|
|
85
|
+
ok: false;
|
|
86
|
+
status: 400 | 401;
|
|
87
|
+
reason: VerifyFailure | "malformed";
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Verifies, then parses. Never reads the body before the signature is checked. Answer 2xx only after the event is
|
|
91
|
+
* stored, and deduplicate on `delivery.id`.
|
|
92
|
+
*/
|
|
93
|
+
export declare function receive<B = EventBody>(input: VerifyInput): ReceiveResult<B>;
|
|
94
|
+
/** The member a member event is about, or null when the delivery carries none. */
|
|
95
|
+
export declare function memberOf(body: EventBody): MemberPayload | null;
|
|
96
|
+
export type { HeaderSource };
|
package/delivery.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { header, verifySignature } from "./signature.js";
|
|
2
|
+
/**
|
|
3
|
+
* Verifies, then parses. Never reads the body before the signature is checked. Answer 2xx only after the event is
|
|
4
|
+
* stored, and deduplicate on `delivery.id`.
|
|
5
|
+
*/
|
|
6
|
+
export function receive(input) {
|
|
7
|
+
const verified = verifySignature(input);
|
|
8
|
+
if (!verified.ok)
|
|
9
|
+
return { ok: false, status: 401, reason: verified.reason };
|
|
10
|
+
const text = typeof input.rawBody === "string" ? input.rawBody : new TextDecoder().decode(input.rawBody);
|
|
11
|
+
let body;
|
|
12
|
+
try {
|
|
13
|
+
body = JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { ok: false, status: 400, reason: "malformed" };
|
|
17
|
+
}
|
|
18
|
+
const id = header(input.headers, "x-manajemin-delivery");
|
|
19
|
+
const event = header(input.headers, "x-manajemin-event");
|
|
20
|
+
if (!id || !event || typeof body !== "object" || body === null)
|
|
21
|
+
return { ok: false, status: 400, reason: "malformed" };
|
|
22
|
+
return {
|
|
23
|
+
ok: true,
|
|
24
|
+
delivery: {
|
|
25
|
+
id,
|
|
26
|
+
event,
|
|
27
|
+
tenantId: header(input.headers, "x-manajemin-tenant") ?? null,
|
|
28
|
+
entityId: header(input.headers, "x-manajemin-entity") ?? null,
|
|
29
|
+
apiVersion: header(input.headers, "x-manajemin-api-version") ?? null,
|
|
30
|
+
traceId: /^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/.exec(header(input.headers, "traceparent") ?? "")?.[1] ?? null,
|
|
31
|
+
body: body,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** The member a member event is about, or null when the delivery carries none. */
|
|
36
|
+
export function memberOf(body) {
|
|
37
|
+
const payload = body.payload;
|
|
38
|
+
if (!payload || typeof payload !== "object" || !("member_id" in payload || "user_id" in payload))
|
|
39
|
+
return null;
|
|
40
|
+
return payload;
|
|
41
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export declare const API_VERSION = "2026-11-01";
|
|
2
|
+
/** Every operation the document describes, as `METHOD /path`. */
|
|
3
|
+
export declare const OPERATIONS: readonly ["POST /api/integration/ack", "POST /api/integration/uninstall-ack", "POST /api/integration/exchange", "POST /api/integration/usage", "POST /api/integration/ack-removal", "GET /api/integration/snapshot", "POST /api/integration/guest-grants", "GET /api/integration/members", "POST /api/integration/audit", "POST /api/integration/telemetry", "GET /api/integration/files", "POST /api/integration/files", "POST /api/integration/files/{fileId}/complete", "GET /api/integration/files/{fileId}", "DELETE /api/integration/files/{fileId}", "POST /api/integration/notify", "POST /api/integration/subject-erasure-ack", "POST /api/integration/export-ready", "GET /api/integration/events", "POST /api/integration/events", "POST /api/integration/ai"];
|
|
4
|
+
export type Operation = (typeof OPERATIONS)[number];
|
|
5
|
+
/** The JSON body an operation takes, where it takes one. */
|
|
6
|
+
export interface ApiBodies {
|
|
7
|
+
"POST /api/integration/ack": {
|
|
8
|
+
entity_id: string;
|
|
9
|
+
};
|
|
10
|
+
"POST /api/integration/uninstall-ack": {
|
|
11
|
+
entity_id: string;
|
|
12
|
+
};
|
|
13
|
+
"POST /api/integration/exchange": {
|
|
14
|
+
code: string;
|
|
15
|
+
entity_id?: string;
|
|
16
|
+
};
|
|
17
|
+
"POST /api/integration/usage": {
|
|
18
|
+
events: Array<{
|
|
19
|
+
metric: string;
|
|
20
|
+
qty: number;
|
|
21
|
+
entity_id: string;
|
|
22
|
+
dedupe_key: string;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
"POST /api/integration/ack-removal": {
|
|
26
|
+
member_id: string;
|
|
27
|
+
outbox_id: string;
|
|
28
|
+
};
|
|
29
|
+
"POST /api/integration/guest-grants": {
|
|
30
|
+
entityId: string;
|
|
31
|
+
email: string;
|
|
32
|
+
scope?: "read_only" | "comment";
|
|
33
|
+
};
|
|
34
|
+
"POST /api/integration/audit": {
|
|
35
|
+
entity_id: string;
|
|
36
|
+
event: string;
|
|
37
|
+
summary?: string;
|
|
38
|
+
target_type?: string;
|
|
39
|
+
target_id?: string;
|
|
40
|
+
member_id?: string;
|
|
41
|
+
details?: Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
"POST /api/integration/telemetry": {
|
|
44
|
+
entity_id: string;
|
|
45
|
+
events: Array<{
|
|
46
|
+
feature: string;
|
|
47
|
+
member_id?: string;
|
|
48
|
+
occurred_at?: string;
|
|
49
|
+
}>;
|
|
50
|
+
};
|
|
51
|
+
"POST /api/integration/files": {
|
|
52
|
+
entity_id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
content_type: string;
|
|
55
|
+
size_bytes: number;
|
|
56
|
+
};
|
|
57
|
+
"POST /api/integration/files/{fileId}/complete": {
|
|
58
|
+
entity_id: string;
|
|
59
|
+
};
|
|
60
|
+
"POST /api/integration/notify": {
|
|
61
|
+
entity_id: string;
|
|
62
|
+
member_id: string;
|
|
63
|
+
subject: string;
|
|
64
|
+
message: string;
|
|
65
|
+
action_label?: string;
|
|
66
|
+
action_url?: string;
|
|
67
|
+
};
|
|
68
|
+
"POST /api/integration/subject-erasure-ack": {
|
|
69
|
+
request_id: string;
|
|
70
|
+
entity_id: string;
|
|
71
|
+
};
|
|
72
|
+
"POST /api/integration/export-ready": {
|
|
73
|
+
export_id: string;
|
|
74
|
+
entity_id: string;
|
|
75
|
+
url: string;
|
|
76
|
+
expires_at: string;
|
|
77
|
+
bytes?: number;
|
|
78
|
+
};
|
|
79
|
+
"POST /api/integration/events": {
|
|
80
|
+
entity_id: string;
|
|
81
|
+
type: string;
|
|
82
|
+
data: Record<string, unknown>;
|
|
83
|
+
idempotency_key?: string;
|
|
84
|
+
occurred_at?: string;
|
|
85
|
+
};
|
|
86
|
+
"POST /api/integration/ai": {
|
|
87
|
+
entity_id: string;
|
|
88
|
+
member_id: string;
|
|
89
|
+
feature?: string;
|
|
90
|
+
system?: string;
|
|
91
|
+
messages: Array<{
|
|
92
|
+
role: "user" | "assistant";
|
|
93
|
+
content: string;
|
|
94
|
+
}>;
|
|
95
|
+
max_tokens?: number;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** The query parameters an operation takes, where it takes any. */
|
|
99
|
+
export interface ApiQueries {
|
|
100
|
+
"GET /api/integration/snapshot": {
|
|
101
|
+
app_id: string;
|
|
102
|
+
entity_id: string;
|
|
103
|
+
};
|
|
104
|
+
"GET /api/integration/members": {
|
|
105
|
+
entity_id: string;
|
|
106
|
+
};
|
|
107
|
+
"GET /api/integration/files": {
|
|
108
|
+
entity_id: string;
|
|
109
|
+
};
|
|
110
|
+
"GET /api/integration/files/{fileId}": {
|
|
111
|
+
entity_id: string;
|
|
112
|
+
};
|
|
113
|
+
"DELETE /api/integration/files/{fileId}": {
|
|
114
|
+
entity_id: string;
|
|
115
|
+
};
|
|
116
|
+
"GET /api/integration/events": {
|
|
117
|
+
entity_id: string;
|
|
118
|
+
after?: number;
|
|
119
|
+
limit?: number;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export type ApiBody<K extends keyof ApiBodies> = ApiBodies[K];
|
|
123
|
+
export type ApiQuery<K extends keyof ApiQueries> = ApiQueries[K];
|
package/generated/api.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Generated by scripts/gen-sdk-api.mjs from doc/integration/openapi.yaml. Do not edit by hand: change the document and run `npm run sdk:generate`.
|
|
2
|
+
export const API_VERSION = "2026-11-01";
|
|
3
|
+
/** Every operation the document describes, as `METHOD /path`. */
|
|
4
|
+
export const OPERATIONS = [
|
|
5
|
+
"POST /api/integration/ack",
|
|
6
|
+
"POST /api/integration/uninstall-ack",
|
|
7
|
+
"POST /api/integration/exchange",
|
|
8
|
+
"POST /api/integration/usage",
|
|
9
|
+
"POST /api/integration/ack-removal",
|
|
10
|
+
"GET /api/integration/snapshot",
|
|
11
|
+
"POST /api/integration/guest-grants",
|
|
12
|
+
"GET /api/integration/members",
|
|
13
|
+
"POST /api/integration/audit",
|
|
14
|
+
"POST /api/integration/telemetry",
|
|
15
|
+
"GET /api/integration/files",
|
|
16
|
+
"POST /api/integration/files",
|
|
17
|
+
"POST /api/integration/files/{fileId}/complete",
|
|
18
|
+
"GET /api/integration/files/{fileId}",
|
|
19
|
+
"DELETE /api/integration/files/{fileId}",
|
|
20
|
+
"POST /api/integration/notify",
|
|
21
|
+
"POST /api/integration/subject-erasure-ack",
|
|
22
|
+
"POST /api/integration/export-ready",
|
|
23
|
+
"GET /api/integration/events",
|
|
24
|
+
"POST /api/integration/events",
|
|
25
|
+
"POST /api/integration/ai"
|
|
26
|
+
];
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { DEFAULT_TOLERANCE_SECONDS, SIGNATURE_HEADER, TIMESTAMP_HEADER, sign, verifySignature } from "./signature.ts";
|
|
2
|
+
export type { HeaderSource, VerifyFailure, VerifyInput, VerifyResult } from "./signature.ts";
|
|
3
|
+
export { memberOf, receive } from "./delivery.ts";
|
|
4
|
+
export type { AppEventBody, Delivery, EventBody, ExportRequestBody, MemberPayload, ReceiveResult, SubjectErasureBody, ToolCallBody } from "./delivery.ts";
|
|
5
|
+
export { DEFAULT_BASE_URL, ManajeminApiError, ManajeminClient, safeRedirect } from "./client.ts";
|
|
6
|
+
export type { AppEvent, Claims, ClientOptions, InstallTokenStore, Member, StoredFile, UsageEvent } from "./client.ts";
|
|
7
|
+
export { guardTenantTables, TenantContextError, withTenant } from "./tenant.ts";
|
|
8
|
+
export type { TenantPool, TenantPoolClient, TenantQueryable } from "./tenant.ts";
|
package/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { DEFAULT_TOLERANCE_SECONDS, SIGNATURE_HEADER, TIMESTAMP_HEADER, sign, verifySignature } from "./signature.js";
|
|
2
|
+
export { memberOf, receive } from "./delivery.js";
|
|
3
|
+
export { DEFAULT_BASE_URL, ManajeminApiError, ManajeminClient, safeRedirect } from "./client.js";
|
|
4
|
+
export { guardTenantTables, TenantContextError, withTenant } from "./tenant.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manajemin-dev-cli/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Connect an app to the Manajemin platform: verify deliveries, redeem SSO codes, acknowledge installs, report usage.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./index.js",
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"default": "./index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"manajemin",
|
|
20
|
+
"sdk",
|
|
21
|
+
"webhook",
|
|
22
|
+
"sso"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/signature.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook and tool-call verification, the part developers most often get wrong. The rules are the published contract
|
|
3
|
+
* (`contract.json`, `signature-vectors.json`), not this file: the tests run every published vector.
|
|
4
|
+
*
|
|
5
|
+
* X-Manajemin-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>] (one v1 per secret during a webhook secret rotation)
|
|
6
|
+
* X-Manajemin-Timestamp: the same <unix seconds>
|
|
7
|
+
* hex = HMAC-SHA256(webhook secret, "<t>.<raw body>")
|
|
8
|
+
*/
|
|
9
|
+
export declare const SIGNATURE_HEADER = "x-manajemin-signature";
|
|
10
|
+
export declare const TIMESTAMP_HEADER = "x-manajemin-timestamp";
|
|
11
|
+
export declare const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
12
|
+
export type HeaderSource = Headers | Record<string, string | string[] | undefined>;
|
|
13
|
+
export type VerifyFailure = "missing" | "stale" | "mismatched";
|
|
14
|
+
export type VerifyResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: VerifyFailure;
|
|
19
|
+
};
|
|
20
|
+
export interface VerifyInput {
|
|
21
|
+
/** The webhook secret, or several while you deploy a new one: the delivery is valid if any of them matches. */
|
|
22
|
+
secret: string | readonly string[];
|
|
23
|
+
headers: HeaderSource;
|
|
24
|
+
/** The body exactly as received. Never a re-serialised object: one changed byte breaks the signature. */
|
|
25
|
+
rawBody: string | Uint8Array;
|
|
26
|
+
/** Unix seconds; defaults to the clock. */
|
|
27
|
+
now?: number;
|
|
28
|
+
toleranceSeconds?: number;
|
|
29
|
+
}
|
|
30
|
+
export declare function header(headers: HeaderSource, name: string): string | undefined;
|
|
31
|
+
export declare function sign(secret: string, timestamp: number | string, rawBody: string | Uint8Array): string;
|
|
32
|
+
/** Order: missing or malformed headers, then age, then the MAC in constant time. */
|
|
33
|
+
export declare function verifySignature(input: VerifyInput): VerifyResult;
|
package/signature.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Webhook and tool-call verification, the part developers most often get wrong. The rules are the published contract
|
|
4
|
+
* (`contract.json`, `signature-vectors.json`), not this file: the tests run every published vector.
|
|
5
|
+
*
|
|
6
|
+
* X-Manajemin-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>] (one v1 per secret during a webhook secret rotation)
|
|
7
|
+
* X-Manajemin-Timestamp: the same <unix seconds>
|
|
8
|
+
* hex = HMAC-SHA256(webhook secret, "<t>.<raw body>")
|
|
9
|
+
*/
|
|
10
|
+
export const SIGNATURE_HEADER = "x-manajemin-signature";
|
|
11
|
+
export const TIMESTAMP_HEADER = "x-manajemin-timestamp";
|
|
12
|
+
export const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
13
|
+
/** More than this many v1 entries is not something the platform sends; refusing bounds the work per request. */
|
|
14
|
+
const MAX_SIGNATURES = 8;
|
|
15
|
+
export function header(headers, name) {
|
|
16
|
+
if (typeof headers.get === "function")
|
|
17
|
+
return headers.get(name) ?? undefined;
|
|
18
|
+
const record = headers;
|
|
19
|
+
const key = Object.keys(record).find((k) => k.toLowerCase() === name);
|
|
20
|
+
const value = key === undefined ? undefined : record[key];
|
|
21
|
+
return Array.isArray(value) ? value[0] : value;
|
|
22
|
+
}
|
|
23
|
+
export function sign(secret, timestamp, rawBody) {
|
|
24
|
+
return createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
/** Order: missing or malformed headers, then age, then the MAC in constant time. */
|
|
27
|
+
export function verifySignature(input) {
|
|
28
|
+
const timestamp = header(input.headers, TIMESTAMP_HEADER);
|
|
29
|
+
const signature = header(input.headers, SIGNATURE_HEADER);
|
|
30
|
+
if (!timestamp || !/^\d+$/.test(timestamp) || !signature)
|
|
31
|
+
return { ok: false, reason: "missing" };
|
|
32
|
+
const match = /^t=(\d+)((?:,v1=[0-9a-f]{64})+)$/.exec(signature.trim());
|
|
33
|
+
if (!match)
|
|
34
|
+
return { ok: false, reason: "missing" };
|
|
35
|
+
const candidates = match[2].slice(1).split(",").map((entry) => entry.slice(3));
|
|
36
|
+
if (candidates.length > MAX_SIGNATURES)
|
|
37
|
+
return { ok: false, reason: "missing" };
|
|
38
|
+
const now = input.now ?? Math.floor(Date.now() / 1000);
|
|
39
|
+
if (Math.abs(now - Number(timestamp)) > (input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS)) {
|
|
40
|
+
return { ok: false, reason: "stale" };
|
|
41
|
+
}
|
|
42
|
+
// The age was read from the timestamp header and the MAC covers the signature's own t: they must agree, or an old
|
|
43
|
+
// captured signature could be replayed behind a fresh timestamp header.
|
|
44
|
+
if (match[1] !== timestamp)
|
|
45
|
+
return { ok: false, reason: "mismatched" };
|
|
46
|
+
const secrets = typeof input.secret === "string" ? [input.secret] : input.secret;
|
|
47
|
+
for (const secret of secrets) {
|
|
48
|
+
const expected = Buffer.from(sign(secret, timestamp, input.rawBody), "hex");
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
if (timingSafeEqual(expected, Buffer.from(candidate, "hex")))
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { ok: false, reason: "mismatched" };
|
|
55
|
+
}
|
package/tenant.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One live Nile tenant per Manajemin entity (plan D5, as amended 2026-10-25). Nile never frees a deleted tenant's id, so an
|
|
3
|
+
* app must NOT use the entity id as the tenant id: it keeps its own entity-to-tenant table and gives each install a fresh
|
|
4
|
+
* tenant id (the reference app's `workspaces` table shows how). `withTenant` runs a unit of work inside one tenant's context
|
|
5
|
+
* and nowhere else, so the short path is the safe path (plan §15.3: "no query runs outside withTenant").
|
|
6
|
+
*
|
|
7
|
+
* The context is set with `SET LOCAL nile.tenant_id`, local to one transaction, so a pooled connection never carries
|
|
8
|
+
* one entity's context into the next request. Checked against real Nile on 2026-10-22: it accepts `SET LOCAL` right
|
|
9
|
+
* after BEGIN and refuses `select set_config('nile.tenant_id', ..., true)` there ("changing nile.tenant_id within a
|
|
10
|
+
* transaction is only allowed before any queries"). A plain session `SET` also works but outlives the request on a
|
|
11
|
+
* pooled connection, so it is never used here. Any pg-compatible pool works (node-postgres `Pool`); the SDK itself has
|
|
12
|
+
* no database dependency.
|
|
13
|
+
*
|
|
14
|
+
* What this does NOT give: Nile still returns every tenant's rows to a query that runs with no context at all (writes
|
|
15
|
+
* without one are refused). The protection is therefore in the app, in three layers: no tenant-table query runs outside
|
|
16
|
+
* `withTenant`; the pool the rest of the code gets is wrapped by `guardTenantTables`, which refuses a statement that names
|
|
17
|
+
* a tenant table; and a test in the app fails if its source reaches for the raw pool (the reference app has all three).
|
|
18
|
+
*/
|
|
19
|
+
export interface TenantQueryable {
|
|
20
|
+
query<R = Record<string, unknown>>(text: string, values?: unknown[]): Promise<{
|
|
21
|
+
rows: R[];
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export interface TenantPoolClient extends TenantQueryable {
|
|
25
|
+
/** node-postgres: passing an error destroys the connection instead of returning it to the pool. */
|
|
26
|
+
release(err?: Error | boolean): void;
|
|
27
|
+
}
|
|
28
|
+
export interface TenantPool {
|
|
29
|
+
connect(): Promise<TenantPoolClient>;
|
|
30
|
+
}
|
|
31
|
+
export declare function withTenant<T>(pool: TenantPool, tenantId: string, work: (db: TenantQueryable) => Promise<T>): Promise<T>;
|
|
32
|
+
/** Thrown by `guardTenantTables` for a statement that names a tenant table outside `withTenant`. */
|
|
33
|
+
export declare class TenantContextError extends Error {
|
|
34
|
+
readonly table: string;
|
|
35
|
+
constructor(table: string);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Wraps the plain pool the rest of the app queries through, for the tables that are NOT tenant tables. A statement that names
|
|
39
|
+
* one of `tenantTables` is refused before it is sent, so a forgotten `withTenant` fails loudly in development and in tests
|
|
40
|
+
* instead of quietly reading every tenant's rows on Nile (backlog -114). It is a guard against the realistic mistake, not a
|
|
41
|
+
* proof: dynamic SQL built elsewhere, a view or a function can still reach a tenant table, which is why the app's own test
|
|
42
|
+
* also scans its source.
|
|
43
|
+
*/
|
|
44
|
+
export declare function guardTenantTables(db: TenantQueryable, tenantTables: readonly string[]): TenantQueryable;
|
package/tenant.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One live Nile tenant per Manajemin entity (plan D5, as amended 2026-10-25). Nile never frees a deleted tenant's id, so an
|
|
3
|
+
* app must NOT use the entity id as the tenant id: it keeps its own entity-to-tenant table and gives each install a fresh
|
|
4
|
+
* tenant id (the reference app's `workspaces` table shows how). `withTenant` runs a unit of work inside one tenant's context
|
|
5
|
+
* and nowhere else, so the short path is the safe path (plan §15.3: "no query runs outside withTenant").
|
|
6
|
+
*
|
|
7
|
+
* The context is set with `SET LOCAL nile.tenant_id`, local to one transaction, so a pooled connection never carries
|
|
8
|
+
* one entity's context into the next request. Checked against real Nile on 2026-10-22: it accepts `SET LOCAL` right
|
|
9
|
+
* after BEGIN and refuses `select set_config('nile.tenant_id', ..., true)` there ("changing nile.tenant_id within a
|
|
10
|
+
* transaction is only allowed before any queries"). A plain session `SET` also works but outlives the request on a
|
|
11
|
+
* pooled connection, so it is never used here. Any pg-compatible pool works (node-postgres `Pool`); the SDK itself has
|
|
12
|
+
* no database dependency.
|
|
13
|
+
*
|
|
14
|
+
* What this does NOT give: Nile still returns every tenant's rows to a query that runs with no context at all (writes
|
|
15
|
+
* without one are refused). The protection is therefore in the app, in three layers: no tenant-table query runs outside
|
|
16
|
+
* `withTenant`; the pool the rest of the code gets is wrapped by `guardTenantTables`, which refuses a statement that names
|
|
17
|
+
* a tenant table; and a test in the app fails if its source reaches for the raw pool (the reference app has all three).
|
|
18
|
+
*/
|
|
19
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
export async function withTenant(pool, tenantId, work) {
|
|
21
|
+
// Validated before any connection: a malformed id must not become "no tenant context" by accident.
|
|
22
|
+
if (!UUID.test(tenantId))
|
|
23
|
+
throw new Error("withTenant needs the tenant id (a UUID) of the workspace");
|
|
24
|
+
const client = await pool.connect();
|
|
25
|
+
try {
|
|
26
|
+
// `SET` takes no bind parameters, so the id is part of the SQL text: the UUID check above is what makes that safe.
|
|
27
|
+
// BEGIN and the context go in one message (one network round trip fewer; about 200 ms from Jakarta to Frankfurt).
|
|
28
|
+
await client.query(`BEGIN; SET LOCAL nile.tenant_id = '${tenantId}'`);
|
|
29
|
+
const result = await work(client);
|
|
30
|
+
await client.query("COMMIT");
|
|
31
|
+
client.release();
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
36
|
+
client.release(err instanceof Error ? err : true);
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Thrown by `guardTenantTables` for a statement that names a tenant table outside `withTenant`. */
|
|
41
|
+
export class TenantContextError extends Error {
|
|
42
|
+
table;
|
|
43
|
+
constructor(table) {
|
|
44
|
+
super(`this statement names the tenant table "${table}" outside withTenant(); run it inside withTenant(tenantId, ...)`);
|
|
45
|
+
this.name = "TenantContextError";
|
|
46
|
+
this.table = table;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Removes string literals and comments in ONE left-to-right pass, so each is recognised where it starts: a `--` inside a string is part
|
|
51
|
+
* of the string and a quote inside a comment is part of the comment. Two ordered replacements cannot do this (`select '--' from tasks`
|
|
52
|
+
* would lose the table name to the comment rule), which would let the very statement the guard exists to refuse through.
|
|
53
|
+
*/
|
|
54
|
+
function stripLiteralsAndComments(sql) {
|
|
55
|
+
return sql.replace(/'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g, (match) => (match.startsWith("'") ? "''" : " "));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Wraps the plain pool the rest of the app queries through, for the tables that are NOT tenant tables. A statement that names
|
|
59
|
+
* one of `tenantTables` is refused before it is sent, so a forgotten `withTenant` fails loudly in development and in tests
|
|
60
|
+
* instead of quietly reading every tenant's rows on Nile (backlog -114). It is a guard against the realistic mistake, not a
|
|
61
|
+
* proof: dynamic SQL built elsewhere, a view or a function can still reach a tenant table, which is why the app's own test
|
|
62
|
+
* also scans its source.
|
|
63
|
+
*/
|
|
64
|
+
export function guardTenantTables(db, tenantTables) {
|
|
65
|
+
const patterns = tenantTables.map((table) => ({ table, re: new RegExp(`(?<![A-Za-z0-9_])"?${table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"?(?![A-Za-z0-9_])`, "i") }));
|
|
66
|
+
return {
|
|
67
|
+
async query(text, values) {
|
|
68
|
+
const sql = stripLiteralsAndComments(text);
|
|
69
|
+
for (const { table, re } of patterns)
|
|
70
|
+
if (re.test(sql))
|
|
71
|
+
throw new TenantContextError(table);
|
|
72
|
+
return db.query(text, values);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|