@formancy/server-core 0.1.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 +202 -0
- package/NOTICE +4 -0
- package/README.md +47 -0
- package/dist/index.d.mts +324 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +546 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/NOTICE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!-- Part of formancy: https://github.com/sharkysan/formancy.ai -->
|
|
2
|
+
|
|
3
|
+
# @formancy/server-core
|
|
4
|
+
|
|
5
|
+
The formancy backend's use-cases, with no HTTP framework and no database driver
|
|
6
|
+
in sight: publish, resolve, submit, drafts, listing, export, and the identity
|
|
7
|
+
and authorization model.
|
|
8
|
+
|
|
9
|
+
Everything talks to a `Storage` port. The same code runs over Postgres in
|
|
10
|
+
`@formancy/server` and over a `Map` in tests, which is what makes these
|
|
11
|
+
behaviours testable without a database and portable across ones.
|
|
12
|
+
|
|
13
|
+
## Two load-bearing decisions
|
|
14
|
+
|
|
15
|
+
**Submission is a replay, not a check.** `createSubmission` rebuilds the engine
|
|
16
|
+
from the exact schema version the client says it rendered, runs it over the
|
|
17
|
+
submitted data, and stores the *canonical* result: computed values are
|
|
18
|
+
recomputed and overwrite whatever arrived, and fields the server evaluates as
|
|
19
|
+
hidden are stripped. A client cannot smuggle data into a hidden branch by lying
|
|
20
|
+
about what was shown. Client validation is UX; this is truth.
|
|
21
|
+
|
|
22
|
+
**Nothing ambient.** Identity, time, hashing and randomness are injected. That
|
|
23
|
+
is what lets a submission be replayed later and produce byte-identical computed
|
|
24
|
+
values — and it is why the auth tests can use deterministic fakes while the real
|
|
25
|
+
server gets argon2id and a CSPRNG.
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { publishForm, createSubmission, can } from '@formancy/server-core'
|
|
31
|
+
|
|
32
|
+
const published = await publishForm(deps, { path: 'contact-us', schema })
|
|
33
|
+
// publishForm is the save gate: structural validation, then the engine's own
|
|
34
|
+
// compile — a form that could loop is refused here and never persisted.
|
|
35
|
+
|
|
36
|
+
const outcome = await createSubmission(deps, {
|
|
37
|
+
path: 'contact-us',
|
|
38
|
+
declaredSchemaHash: published.schemaHash,
|
|
39
|
+
data: payload,
|
|
40
|
+
})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Authorization is one readable table (`can(actor, action)`), deliberately not
|
|
44
|
+
Postgres row-level security: RLS couples the application to per-request database
|
|
45
|
+
roles and hides authorization from the test suite.
|
|
46
|
+
|
|
47
|
+
Docs: `apps/docs` (Quickstart: self-hosting).
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { Change, FormSchema } from "@formancy/spec";
|
|
2
|
+
import { SchemaError } from "@formancy/spec/validate";
|
|
3
|
+
import { CapabilitySource } from "@formancy/core";
|
|
4
|
+
//#region src/auth.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The management plane's identity and authorization, framework-free.
|
|
7
|
+
*
|
|
8
|
+
* Hashing and randomness are injected — this package cannot reach a CSPRNG or
|
|
9
|
+
* argon2 on its own, the composition root hands them in — which keeps every
|
|
10
|
+
* behaviour here testable with deterministic fakes while the real server gets
|
|
11
|
+
* real cryptography.
|
|
12
|
+
*/
|
|
13
|
+
type Role = 'admin' | 'editor' | 'viewer';
|
|
14
|
+
interface Actor {
|
|
15
|
+
kind: 'user' | 'apiKey';
|
|
16
|
+
id: string;
|
|
17
|
+
role: Role;
|
|
18
|
+
}
|
|
19
|
+
interface AuthDeps {
|
|
20
|
+
storage: Storage;
|
|
21
|
+
newId(): string;
|
|
22
|
+
nowIso(): string;
|
|
23
|
+
/** At least 160 bits of entropy, URL-safe. Injected, never Math.random. */
|
|
24
|
+
randomToken(): string;
|
|
25
|
+
hashSecret(secret: string): Promise<string>;
|
|
26
|
+
verifySecret(secret: string, hash: string): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
type CreateUserOutcome = {
|
|
29
|
+
ok: true;
|
|
30
|
+
id: string;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function createLocalUser(deps: AuthDeps, input: {
|
|
36
|
+
email: string;
|
|
37
|
+
password: string;
|
|
38
|
+
role: Role;
|
|
39
|
+
}): Promise<CreateUserOutcome>;
|
|
40
|
+
type AuthOutcome = {
|
|
41
|
+
ok: true;
|
|
42
|
+
actor: Actor;
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A wrong password and an unknown email answer identically, and BOTH run a
|
|
48
|
+
* hash verification — the difference in shape or timing would be a
|
|
49
|
+
* user-enumeration oracle.
|
|
50
|
+
*/
|
|
51
|
+
export declare function authenticateLocal(deps: AuthDeps, input: {
|
|
52
|
+
email: string;
|
|
53
|
+
password: string;
|
|
54
|
+
}): Promise<AuthOutcome>;
|
|
55
|
+
type CreateApiKeyOutcome = {
|
|
56
|
+
ok: true;
|
|
57
|
+
id: string;
|
|
58
|
+
secret: string;
|
|
59
|
+
} | {
|
|
60
|
+
ok: false;
|
|
61
|
+
message: string;
|
|
62
|
+
};
|
|
63
|
+
export declare function createApiKey(deps: AuthDeps, input: {
|
|
64
|
+
name: string;
|
|
65
|
+
role: Role;
|
|
66
|
+
}): Promise<CreateApiKeyOutcome>;
|
|
67
|
+
export declare function authenticateApiKey(deps: AuthDeps, presented: string): Promise<AuthOutcome>;
|
|
68
|
+
/**
|
|
69
|
+
* The whole authorization model, in one table a reviewer can read top to
|
|
70
|
+
* bottom. Deliberately NOT Postgres RLS in this phase: RLS couples the app to
|
|
71
|
+
* per-request database roles and hides authorization from the test suite.
|
|
72
|
+
* An unknown action is denied — permissions are granted by presence, never by
|
|
73
|
+
* omission.
|
|
74
|
+
*/
|
|
75
|
+
type Action = 'form.read' | 'form.publish' | 'submission.read' | 'submission.export' | 'draft.write' | 'user.create' | 'apiKey.create';
|
|
76
|
+
export declare function can(actor: Actor, action: Action): boolean;
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/ports.d.ts
|
|
79
|
+
/**
|
|
80
|
+
* The storage port. Implemented over Postgres in @formancy/server and over a
|
|
81
|
+
* Map in tests — the use-cases cannot tell the difference, which is what makes
|
|
82
|
+
* them testable without a database and portable across ones.
|
|
83
|
+
*/
|
|
84
|
+
interface FormRecord {
|
|
85
|
+
id: string;
|
|
86
|
+
path: string;
|
|
87
|
+
currentVersionId: string | null;
|
|
88
|
+
}
|
|
89
|
+
interface FormVersionRecord {
|
|
90
|
+
id: string;
|
|
91
|
+
formId: string;
|
|
92
|
+
version: number;
|
|
93
|
+
schema: FormSchema;
|
|
94
|
+
schemaHash: string;
|
|
95
|
+
}
|
|
96
|
+
interface SubmissionRecord {
|
|
97
|
+
id: string;
|
|
98
|
+
formId: string;
|
|
99
|
+
formVersionId: string;
|
|
100
|
+
data: unknown;
|
|
101
|
+
submittedAt: string;
|
|
102
|
+
}
|
|
103
|
+
interface UserRecord {
|
|
104
|
+
id: string;
|
|
105
|
+
email: string;
|
|
106
|
+
passwordHash: string;
|
|
107
|
+
role: Role;
|
|
108
|
+
createdAt: string;
|
|
109
|
+
}
|
|
110
|
+
interface ApiKeyRecord {
|
|
111
|
+
id: string;
|
|
112
|
+
name: string;
|
|
113
|
+
/** The visible first characters — index and display, never authentication. */
|
|
114
|
+
prefix: string;
|
|
115
|
+
secretHash: string;
|
|
116
|
+
role: Role;
|
|
117
|
+
createdAt: string;
|
|
118
|
+
revokedAt: string | null;
|
|
119
|
+
}
|
|
120
|
+
interface DraftRecord {
|
|
121
|
+
id: string;
|
|
122
|
+
formId: string;
|
|
123
|
+
formVersionId: string;
|
|
124
|
+
data: unknown;
|
|
125
|
+
updatedAt: string;
|
|
126
|
+
}
|
|
127
|
+
interface Storage {
|
|
128
|
+
getFormByPath(path: string): Promise<FormRecord | undefined>;
|
|
129
|
+
listForms(): Promise<FormRecord[]>;
|
|
130
|
+
createForm(record: FormRecord): Promise<void>;
|
|
131
|
+
setCurrentVersion(formId: string, versionId: string): Promise<void>;
|
|
132
|
+
insertVersion(record: FormVersionRecord): Promise<void>;
|
|
133
|
+
getVersionById(id: string): Promise<FormVersionRecord | undefined>;
|
|
134
|
+
findVersionByHash(formId: string, schemaHash: string): Promise<FormVersionRecord | undefined>;
|
|
135
|
+
latestVersionNumber(formId: string): Promise<number>;
|
|
136
|
+
insertSubmission(record: SubmissionRecord): Promise<void>;
|
|
137
|
+
listSubmissions(): Promise<SubmissionRecord[]>;
|
|
138
|
+
/** Newest first. */
|
|
139
|
+
listSubmissionsByForm(formId: string): Promise<SubmissionRecord[]>;
|
|
140
|
+
listVersionsByForm(formId: string): Promise<FormVersionRecord[]>;
|
|
141
|
+
upsertDraft(record: DraftRecord): Promise<void>;
|
|
142
|
+
getDraft(formId: string, draftId: string): Promise<DraftRecord | undefined>;
|
|
143
|
+
insertUser(record: UserRecord): Promise<void>;
|
|
144
|
+
getUserByEmail(email: string): Promise<UserRecord | undefined>;
|
|
145
|
+
insertApiKey(record: ApiKeyRecord): Promise<void>;
|
|
146
|
+
listApiKeys(): Promise<ApiKeyRecord[]>;
|
|
147
|
+
findApiKeysByPrefix(prefix: string): Promise<ApiKeyRecord[]>;
|
|
148
|
+
revokeApiKey(id: string, atIso: string): Promise<void>;
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/use-cases.d.ts
|
|
152
|
+
/**
|
|
153
|
+
* The backend's use-cases, framework-free.
|
|
154
|
+
*
|
|
155
|
+
* Identity and time are injected like every other capability: the server pins
|
|
156
|
+
* one clock per request, so a submission can be replayed later and produce the
|
|
157
|
+
* same computed values byte for byte. Nothing here reads Date.now or crypto.
|
|
158
|
+
*/
|
|
159
|
+
interface ServerDeps {
|
|
160
|
+
storage: Storage;
|
|
161
|
+
newId(): string;
|
|
162
|
+
nowIso(): string;
|
|
163
|
+
/** The clock/randomness the ENGINE sees during replay. */
|
|
164
|
+
capabilities: CapabilitySource;
|
|
165
|
+
}
|
|
166
|
+
type PublishOutcome = {
|
|
167
|
+
ok: true;
|
|
168
|
+
formId: string;
|
|
169
|
+
versionId: string;
|
|
170
|
+
version: number;
|
|
171
|
+
schemaHash: string;
|
|
172
|
+
} | {
|
|
173
|
+
ok: false;
|
|
174
|
+
kind: 'invalid_schema';
|
|
175
|
+
errors?: SchemaError[];
|
|
176
|
+
} | {
|
|
177
|
+
ok: false;
|
|
178
|
+
kind: 'invalid_logic';
|
|
179
|
+
message: string;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Publish a schema as a form's next version.
|
|
183
|
+
*
|
|
184
|
+
* This is the save gate: structural validation, then the ENGINE's own compile
|
|
185
|
+
* — type-checked expressions, kind policies and the cycle graph — so a form
|
|
186
|
+
* that can loop or read a ghost field is refused here and never persisted.
|
|
187
|
+
* Republishing the identical document is idempotent, because "deploy again"
|
|
188
|
+
* must never manufacture a version.
|
|
189
|
+
*/
|
|
190
|
+
export declare function publishForm(deps: ServerDeps, input: {
|
|
191
|
+
path: string;
|
|
192
|
+
schema: unknown;
|
|
193
|
+
}): Promise<PublishOutcome>;
|
|
194
|
+
interface ResolvedForm {
|
|
195
|
+
formId: string;
|
|
196
|
+
versionId: string;
|
|
197
|
+
version: number;
|
|
198
|
+
schema: FormSchema;
|
|
199
|
+
schemaHash: string;
|
|
200
|
+
}
|
|
201
|
+
export declare function resolveForm(deps: ServerDeps, path: string): Promise<ResolvedForm | undefined>;
|
|
202
|
+
type SubmissionOutcome = {
|
|
203
|
+
ok: true;
|
|
204
|
+
id: string;
|
|
205
|
+
canonicalData: unknown;
|
|
206
|
+
} | {
|
|
207
|
+
ok: false;
|
|
208
|
+
kind: 'unknown_form';
|
|
209
|
+
} | {
|
|
210
|
+
ok: false;
|
|
211
|
+
kind: 'version_changed';
|
|
212
|
+
current?: ResolvedForm;
|
|
213
|
+
} | {
|
|
214
|
+
ok: false;
|
|
215
|
+
kind: 'invalid';
|
|
216
|
+
errors: Record<string, string[]>;
|
|
217
|
+
};
|
|
218
|
+
/**
|
|
219
|
+
* Accept one submission: resolve the version the client says it rendered,
|
|
220
|
+
* replay the engine over the submitted data, and store the CANONICAL result.
|
|
221
|
+
*
|
|
222
|
+
* The replay is the whole point. Computed values are recomputed and overwrite
|
|
223
|
+
* whatever arrived; visibility is evaluated server-side and hidden branches
|
|
224
|
+
* are stripped, so a client cannot smuggle data by lying about what was shown.
|
|
225
|
+
* Client validation is UX; this is truth.
|
|
226
|
+
*/
|
|
227
|
+
export declare function createSubmission(deps: ServerDeps, input: {
|
|
228
|
+
path: string;
|
|
229
|
+
declaredSchemaHash: string;
|
|
230
|
+
data: unknown;
|
|
231
|
+
}): Promise<SubmissionOutcome>;
|
|
232
|
+
interface ListedSubmission {
|
|
233
|
+
id: string;
|
|
234
|
+
version: number;
|
|
235
|
+
submittedAt: string;
|
|
236
|
+
data: unknown;
|
|
237
|
+
}
|
|
238
|
+
/** The submissions of one form, newest first, each tagged with the schema
|
|
239
|
+
* version that produced it. Undefined for an unknown form: the caller must
|
|
240
|
+
* 404, and an empty list would lie about that. */
|
|
241
|
+
export declare function listSubmissions(deps: ServerDeps, path: string): Promise<ListedSubmission[] | undefined>;
|
|
242
|
+
/**
|
|
243
|
+
* Every submission of a form as CSV, columns UNIONED across schema versions:
|
|
244
|
+
* the current version's columns lead in its own order, and columns that only
|
|
245
|
+
* exist in older versions follow — data outlives the field that collected it,
|
|
246
|
+
* and an export that silently dropped extinct columns would lose it.
|
|
247
|
+
*
|
|
248
|
+
* A repeater is one column carrying its rows as JSON: rows-per-cell beats
|
|
249
|
+
* exploding one submission across several CSV lines, which breaks every
|
|
250
|
+
* spreadsheet join a business user will try.
|
|
251
|
+
*/
|
|
252
|
+
export declare function exportCsv(deps: ServerDeps, path: string): Promise<string | undefined>;
|
|
253
|
+
/** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
|
|
254
|
+
* for an unknown form. */
|
|
255
|
+
export declare function saveDraft(deps: ServerDeps, input: {
|
|
256
|
+
path: string;
|
|
257
|
+
draftId: string;
|
|
258
|
+
data: unknown;
|
|
259
|
+
}): Promise<{
|
|
260
|
+
version: number;
|
|
261
|
+
} | undefined>;
|
|
262
|
+
interface DraftMigration {
|
|
263
|
+
severity: 'lossy';
|
|
264
|
+
changes: Change[];
|
|
265
|
+
}
|
|
266
|
+
type ResumeOutcome = {
|
|
267
|
+
outcome: 'resumed';
|
|
268
|
+
version: number;
|
|
269
|
+
schema: FormSchema;
|
|
270
|
+
schemaHash: string;
|
|
271
|
+
data: unknown;
|
|
272
|
+
/** Present when the rebind lost something; absent for a silent rebind. */
|
|
273
|
+
migration?: DraftMigration;
|
|
274
|
+
} | {
|
|
275
|
+
/** The schema changed in a way no automatic rebind survives: the draft
|
|
276
|
+
* comes back against ITS OWN version, and the caller offers a restart. */
|
|
277
|
+
outcome: 'readOnly';
|
|
278
|
+
version: number;
|
|
279
|
+
schema: FormSchema;
|
|
280
|
+
data: unknown;
|
|
281
|
+
};
|
|
282
|
+
/**
|
|
283
|
+
* Resume a draft — and migrate it LAZILY if the form was republished since,
|
|
284
|
+
* which is versioning rule five: never migrate eagerly on publish, because
|
|
285
|
+
* most drafts are abandoned and eager migration multiplies every publish by
|
|
286
|
+
* every draft.
|
|
287
|
+
*
|
|
288
|
+
* diffSchemas drives the decision: `compatible` rebinds silently, `lossy`
|
|
289
|
+
* rebinds with a report — declared renames carry their value to the new key,
|
|
290
|
+
* removed fields move their value into `__orphaned` rather than deleting it —
|
|
291
|
+
* and `breaking` refuses to rebind at all. A successful rebind is persisted,
|
|
292
|
+
* so the migration runs once, not on every resume.
|
|
293
|
+
*/
|
|
294
|
+
export declare function resumeDraft(deps: ServerDeps, input: {
|
|
295
|
+
path: string;
|
|
296
|
+
draftId: string;
|
|
297
|
+
}): Promise<ResumeOutcome | undefined>;
|
|
298
|
+
interface ListedForm {
|
|
299
|
+
path: string;
|
|
300
|
+
title: string;
|
|
301
|
+
version: number;
|
|
302
|
+
schemaHash: string;
|
|
303
|
+
}
|
|
304
|
+
/** Every form with its current version — the admin's landing view. */
|
|
305
|
+
export declare function listForms(deps: ServerDeps): Promise<ListedForm[]>;
|
|
306
|
+
interface ListedVersion {
|
|
307
|
+
version: number;
|
|
308
|
+
schemaHash: string;
|
|
309
|
+
title: string;
|
|
310
|
+
}
|
|
311
|
+
/** A form's version history, newest first — metadata only, because a history
|
|
312
|
+
* view rendering fifty full schemas would be its own denial of service. */
|
|
313
|
+
export declare function listVersions(deps: ServerDeps, path: string): Promise<ListedVersion[] | undefined>;
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/testing/memory-storage.d.ts
|
|
316
|
+
/**
|
|
317
|
+
* The in-memory Storage — the second implementation that keeps the port
|
|
318
|
+
* honest, and the one unit tests run against. Deliberately dumb: Maps and
|
|
319
|
+
* scans, no cleverness to accidentally diverge from SQL semantics.
|
|
320
|
+
*/
|
|
321
|
+
export declare function createMemoryStorage(): Storage;
|
|
322
|
+
//#endregion
|
|
323
|
+
export type { Action, Actor, AuthDeps, AuthOutcome, DraftMigration, DraftRecord, FormRecord, FormVersionRecord, ListedForm, ListedSubmission, ListedVersion, PublishOutcome, ResolvedForm, ResumeOutcome, Role, ServerDeps, Storage, SubmissionOutcome, SubmissionRecord };
|
|
324
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/auth.ts","../src/ports.ts","../src/use-cases.ts","../src/testing/memory-storage.ts"],"mappings":";;;;;;;;;;;;KAUY;UAEK;EACf;EACA;EACA,MAAM;;UAGS;EACf,SAAS;EACT;EACA;;EAEA;EACA,WAAW,iBAAiB;EAC5B,aAAa,gBAAgB,eAAe;;KAKlC;EACN;EAAU;;EACV;EAAW;;wBAEK,gBACpB,MAAM,UACN;EAAS;EAAe;EAAkB,MAAM;IAC/C,QAAQ;KAmBC;EAAgB;EAAU,OAAO;;EAAY;;;;;;;wBAOnC,kBACpB,MAAM,UACN;EAAS;EAAe;IACvB,QAAQ;KAYC;EACN;EAAU;EAAY;;EACtB;EAAW;;wBAEK,aACpB,MAAM,UACN;EAAS;EAAc,MAAM;IAC5B,QAAQ;wBAmBW,mBAAmB,MAAM,UAAU,oBAAoB,QAAQ;;;;;;;;KAkBzE;wBAwBI,IAAI,OAAO,OAAO,QAAQ;;;;;;;;UC1IzB;EACf;EACA;EACA;;UAGe;EACf;EACA;EACA;EACA,QAAQ;EACR;;UAGe;EACf;EACA;EACA;EACA;EACA;;UAGe;EACf;EACA;EACA;EACA,MAAM;EACN;;UAGe;EACf;EACA;;EAEA;EACA;EACA,MAAM;EACN;EACA;;UAGe;EACf;EACA;EACA;EACA;EACA;;UAGe;EACf,cAAc,eAAe,QAAQ;EACrC,aAAa,QAAQ;EACrB,WAAW,QAAQ,aAAa;EAChC,kBAAkB,gBAAgB,oBAAoB;EACtD,cAAc,QAAQ,oBAAoB;EAC1C,eAAe,aAAa,QAAQ;EACpC,kBAAkB,gBAAgB,qBAAqB,QAAQ;EAC/D,oBAAoB,iBAAiB;EACrC,iBAAiB,QAAQ,mBAAmB;EAC5C,mBAAmB,QAAQ;;EAE3B,sBAAsB,iBAAiB,QAAQ;EAC/C,mBAAmB,iBAAiB,QAAQ;EAC5C,YAAY,QAAQ,cAAc;EAClC,SAAS,gBAAgB,kBAAkB,QAAQ;EACnD,WAAW,QAAQ,aAAa;EAChC,eAAe,gBAAgB,QAAQ;EACvC,aAAa,QAAQ,eAAe;EACpC,eAAe,QAAQ;EACvB,oBAAoB,iBAAiB,QAAQ;EAC7C,aAAa,YAAY,gBAAgB;;;;;;;;;;;UC/D1B;EACf,SAAS;EACT;EACA;;EAEA,cAAc;;KAGJ;EACN;EAAU;EAAgB;EAAmB;EAAiB;;EAC9D;EAAW;EAAwB,SAAS;;EAC5C;EAAW;EAAuB;;;;;;;;;;;wBAWlB,YACpB,MAAM,YACN;EAAS;EAAc;IACtB,QAAQ;UAsDM;EACf;EACA;EACA;EACA,QAAQ;EACR;;wBAGoB,YAAY,MAAM,YAAY,eAAe,QAAQ;KAc/D;EACN;EAAU;EAAY;;EACtB;EAAW;;EACX;EAAW;EAAyB,UAAU;;EAC9C;EAAW;EAAiB,QAAQ;;;;;;;;;;;wBAWpB,iBACpB,MAAM,YACN;EAAS;EAAc;EAA4B;IAClD,QAAQ;UA6BM;EACf;EACA;EACA;EACA;;;;;wBAMoB,gBACpB,MAAM,YACN,eACC,QAAQ;;;;;;;;;;;wBA0BW,UAAU,MAAM,YAAY,eAAe;;;wBAwE3C,UACpB,MAAM,YACN;EAAS;EAAc;EAAiB;IACvC;EAAU;;UAcI;EACf;EACA,SAAS;;KAGC;EAEN;EACA;EACA,QAAQ;EACR;EACA;;EAEA,YAAY;;;;EAKZ;EACA;EACA,QAAQ;EACR;;;;;;;;;;;;;;wBAegB,YACpB,MAAM,YACN;EAAS;EAAc;IACtB,QAAQ;UA4HM;EACf;EACA;EACA;EACA;;;wBAIoB,UAAU,MAAM,aAAa,QAAQ;UAiB1C;EACf;EACA;EACA;;;;wBAKoB,aACpB,MAAM,YACN,eACC,QAAQ;;;;;;;;wBCneK,uBAAuB"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { diffSchemas, schemaHash } from "@formancy/spec";
|
|
2
|
+
import { validateSchema } from "@formancy/spec/validate";
|
|
3
|
+
import { createFormEngine } from "@formancy/core";
|
|
4
|
+
//#region src/use-cases.ts
|
|
5
|
+
/**
|
|
6
|
+
* Publish a schema as a form's next version.
|
|
7
|
+
*
|
|
8
|
+
* This is the save gate: structural validation, then the ENGINE's own compile
|
|
9
|
+
* — type-checked expressions, kind policies and the cycle graph — so a form
|
|
10
|
+
* that can loop or read a ghost field is refused here and never persisted.
|
|
11
|
+
* Republishing the identical document is idempotent, because "deploy again"
|
|
12
|
+
* must never manufacture a version.
|
|
13
|
+
*/
|
|
14
|
+
async function publishForm(deps, input) {
|
|
15
|
+
const validated = validateSchema(input.schema);
|
|
16
|
+
if (!validated.valid) return {
|
|
17
|
+
ok: false,
|
|
18
|
+
kind: "invalid_schema",
|
|
19
|
+
errors: validated.errors
|
|
20
|
+
};
|
|
21
|
+
const schema = validated.schema;
|
|
22
|
+
try {
|
|
23
|
+
createFormEngine({
|
|
24
|
+
schema,
|
|
25
|
+
capabilities: deps.capabilities
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
kind: "invalid_logic",
|
|
31
|
+
message: error instanceof Error ? error.message : String(error)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const hash = schemaHash(schema);
|
|
35
|
+
const existing = await deps.storage.getFormByPath(input.path);
|
|
36
|
+
if (existing !== void 0) {
|
|
37
|
+
const current = existing.currentVersionId === null ? void 0 : await deps.storage.getVersionById(existing.currentVersionId);
|
|
38
|
+
if (current !== void 0 && current.schemaHash === hash) return {
|
|
39
|
+
ok: true,
|
|
40
|
+
formId: existing.id,
|
|
41
|
+
versionId: current.id,
|
|
42
|
+
version: current.version,
|
|
43
|
+
schemaHash: hash
|
|
44
|
+
};
|
|
45
|
+
const version = await deps.storage.latestVersionNumber(existing.id) + 1;
|
|
46
|
+
const versionId = deps.newId();
|
|
47
|
+
await deps.storage.insertVersion({
|
|
48
|
+
id: versionId,
|
|
49
|
+
formId: existing.id,
|
|
50
|
+
version,
|
|
51
|
+
schema,
|
|
52
|
+
schemaHash: hash
|
|
53
|
+
});
|
|
54
|
+
await deps.storage.setCurrentVersion(existing.id, versionId);
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
formId: existing.id,
|
|
58
|
+
versionId,
|
|
59
|
+
version,
|
|
60
|
+
schemaHash: hash
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const formId = deps.newId();
|
|
64
|
+
const versionId = deps.newId();
|
|
65
|
+
await deps.storage.createForm({
|
|
66
|
+
id: formId,
|
|
67
|
+
path: input.path,
|
|
68
|
+
currentVersionId: null
|
|
69
|
+
});
|
|
70
|
+
await deps.storage.insertVersion({
|
|
71
|
+
id: versionId,
|
|
72
|
+
formId,
|
|
73
|
+
version: 1,
|
|
74
|
+
schema,
|
|
75
|
+
schemaHash: hash
|
|
76
|
+
});
|
|
77
|
+
await deps.storage.setCurrentVersion(formId, versionId);
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
formId,
|
|
81
|
+
versionId,
|
|
82
|
+
version: 1,
|
|
83
|
+
schemaHash: hash
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function resolveForm(deps, path) {
|
|
87
|
+
const form = await deps.storage.getFormByPath(path);
|
|
88
|
+
if (form === void 0 || form.currentVersionId === null) return void 0;
|
|
89
|
+
const current = await deps.storage.getVersionById(form.currentVersionId);
|
|
90
|
+
if (current === void 0) return void 0;
|
|
91
|
+
return {
|
|
92
|
+
formId: form.id,
|
|
93
|
+
versionId: current.id,
|
|
94
|
+
version: current.version,
|
|
95
|
+
schema: current.schema,
|
|
96
|
+
schemaHash: current.schemaHash
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Accept one submission: resolve the version the client says it rendered,
|
|
101
|
+
* replay the engine over the submitted data, and store the CANONICAL result.
|
|
102
|
+
*
|
|
103
|
+
* The replay is the whole point. Computed values are recomputed and overwrite
|
|
104
|
+
* whatever arrived; visibility is evaluated server-side and hidden branches
|
|
105
|
+
* are stripped, so a client cannot smuggle data by lying about what was shown.
|
|
106
|
+
* Client validation is UX; this is truth.
|
|
107
|
+
*/
|
|
108
|
+
async function createSubmission(deps, input) {
|
|
109
|
+
const current = await resolveForm(deps, input.path);
|
|
110
|
+
if (current === void 0) return {
|
|
111
|
+
ok: false,
|
|
112
|
+
kind: "unknown_form"
|
|
113
|
+
};
|
|
114
|
+
if (input.declaredSchemaHash !== current.schemaHash) return {
|
|
115
|
+
ok: false,
|
|
116
|
+
kind: "version_changed",
|
|
117
|
+
current
|
|
118
|
+
};
|
|
119
|
+
const engine = createFormEngine({
|
|
120
|
+
schema: current.schema,
|
|
121
|
+
initialValue: input.data,
|
|
122
|
+
capabilities: deps.capabilities
|
|
123
|
+
});
|
|
124
|
+
const outcome = engine.submit();
|
|
125
|
+
if (!outcome.ok) return {
|
|
126
|
+
ok: false,
|
|
127
|
+
kind: "invalid",
|
|
128
|
+
errors: outcome.errors
|
|
129
|
+
};
|
|
130
|
+
const id = deps.newId();
|
|
131
|
+
await deps.storage.insertSubmission({
|
|
132
|
+
id,
|
|
133
|
+
formId: current.formId,
|
|
134
|
+
formVersionId: current.versionId,
|
|
135
|
+
data: engine.value(),
|
|
136
|
+
submittedAt: deps.nowIso()
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
id,
|
|
141
|
+
canonicalData: engine.value()
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** The submissions of one form, newest first, each tagged with the schema
|
|
145
|
+
* version that produced it. Undefined for an unknown form: the caller must
|
|
146
|
+
* 404, and an empty list would lie about that. */
|
|
147
|
+
async function listSubmissions(deps, path) {
|
|
148
|
+
const form = await deps.storage.getFormByPath(path);
|
|
149
|
+
if (form === void 0) return void 0;
|
|
150
|
+
const versions = await deps.storage.listVersionsByForm(form.id);
|
|
151
|
+
const versionNumberById = new Map(versions.map((version) => [version.id, version.version]));
|
|
152
|
+
return (await deps.storage.listSubmissionsByForm(form.id)).map((record) => ({
|
|
153
|
+
id: record.id,
|
|
154
|
+
version: versionNumberById.get(record.formVersionId) ?? 0,
|
|
155
|
+
submittedAt: record.submittedAt,
|
|
156
|
+
data: record.data
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Every submission of a form as CSV, columns UNIONED across schema versions:
|
|
161
|
+
* the current version's columns lead in its own order, and columns that only
|
|
162
|
+
* exist in older versions follow — data outlives the field that collected it,
|
|
163
|
+
* and an export that silently dropped extinct columns would lose it.
|
|
164
|
+
*
|
|
165
|
+
* A repeater is one column carrying its rows as JSON: rows-per-cell beats
|
|
166
|
+
* exploding one submission across several CSV lines, which breaks every
|
|
167
|
+
* spreadsheet join a business user will try.
|
|
168
|
+
*/
|
|
169
|
+
async function exportCsv(deps, path) {
|
|
170
|
+
const form = await deps.storage.getFormByPath(path);
|
|
171
|
+
if (form === void 0) return void 0;
|
|
172
|
+
const versions = await deps.storage.listVersionsByForm(form.id);
|
|
173
|
+
const versionNumberById = new Map(versions.map((version) => [version.id, version.version]));
|
|
174
|
+
const columns = [];
|
|
175
|
+
const seen = /* @__PURE__ */ new Set();
|
|
176
|
+
for (const version of versions) for (const column of dataColumns(version.schema)) if (!seen.has(column)) {
|
|
177
|
+
seen.add(column);
|
|
178
|
+
columns.push(column);
|
|
179
|
+
}
|
|
180
|
+
const records = await deps.storage.listSubmissionsByForm(form.id);
|
|
181
|
+
const lines = [[
|
|
182
|
+
"id",
|
|
183
|
+
"submittedAt",
|
|
184
|
+
"version",
|
|
185
|
+
...columns
|
|
186
|
+
].map(csvCell).join(",")];
|
|
187
|
+
for (const record of records) {
|
|
188
|
+
const cells = [
|
|
189
|
+
record.id,
|
|
190
|
+
record.submittedAt,
|
|
191
|
+
String(versionNumberById.get(record.formVersionId) ?? 0),
|
|
192
|
+
...columns.map((column) => cellValue(record.data, column))
|
|
193
|
+
];
|
|
194
|
+
lines.push(cells.map(csvCell).join(","));
|
|
195
|
+
}
|
|
196
|
+
return lines.join("\n") + "\n";
|
|
197
|
+
}
|
|
198
|
+
/** One column per collected answer: leaves as dotted paths through groups,
|
|
199
|
+
* a repeater as a single column, pages transparent as always. */
|
|
200
|
+
function dataColumns(schema) {
|
|
201
|
+
const columns = [];
|
|
202
|
+
const walk = (fields, prefix) => {
|
|
203
|
+
for (const field of fields) if (field.type === "page") walk(field.fields ?? [], prefix);
|
|
204
|
+
else if (field.type === "group") walk(field.fields ?? [], `${prefix}${field.key}.`);
|
|
205
|
+
else columns.push(`${prefix}${field.key}`);
|
|
206
|
+
};
|
|
207
|
+
walk(schema.model.fields, "");
|
|
208
|
+
return columns;
|
|
209
|
+
}
|
|
210
|
+
function cellValue(data, column) {
|
|
211
|
+
let current = data;
|
|
212
|
+
for (const segment of column.split(".")) {
|
|
213
|
+
if (current === null || typeof current !== "object") return "";
|
|
214
|
+
current = current[segment];
|
|
215
|
+
}
|
|
216
|
+
if (current === void 0 || current === null) return "";
|
|
217
|
+
if (typeof current === "object") return JSON.stringify(current);
|
|
218
|
+
if (typeof current === "string" && FORMULA_STARTERS.has(current.charAt(0))) return "'" + current;
|
|
219
|
+
return String(current);
|
|
220
|
+
}
|
|
221
|
+
function csvCell(value) {
|
|
222
|
+
return /[",\n\r]/.test(value) ? `"${value.replaceAll("\"", "\"\"")}"` : value;
|
|
223
|
+
}
|
|
224
|
+
/** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
|
|
225
|
+
* for an unknown form. */
|
|
226
|
+
async function saveDraft(deps, input) {
|
|
227
|
+
const current = await resolveForm(deps, input.path);
|
|
228
|
+
if (current === void 0) return void 0;
|
|
229
|
+
await deps.storage.upsertDraft({
|
|
230
|
+
id: input.draftId,
|
|
231
|
+
formId: current.formId,
|
|
232
|
+
formVersionId: current.versionId,
|
|
233
|
+
data: input.data,
|
|
234
|
+
updatedAt: deps.nowIso()
|
|
235
|
+
});
|
|
236
|
+
return { version: current.version };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Resume a draft — and migrate it LAZILY if the form was republished since,
|
|
240
|
+
* which is versioning rule five: never migrate eagerly on publish, because
|
|
241
|
+
* most drafts are abandoned and eager migration multiplies every publish by
|
|
242
|
+
* every draft.
|
|
243
|
+
*
|
|
244
|
+
* diffSchemas drives the decision: `compatible` rebinds silently, `lossy`
|
|
245
|
+
* rebinds with a report — declared renames carry their value to the new key,
|
|
246
|
+
* removed fields move their value into `__orphaned` rather than deleting it —
|
|
247
|
+
* and `breaking` refuses to rebind at all. A successful rebind is persisted,
|
|
248
|
+
* so the migration runs once, not on every resume.
|
|
249
|
+
*/
|
|
250
|
+
async function resumeDraft(deps, input) {
|
|
251
|
+
const current = await resolveForm(deps, input.path);
|
|
252
|
+
if (current === void 0) return void 0;
|
|
253
|
+
const draft = await deps.storage.getDraft(current.formId, input.draftId);
|
|
254
|
+
if (draft === void 0) return void 0;
|
|
255
|
+
if (draft.formVersionId === current.versionId) return {
|
|
256
|
+
outcome: "resumed",
|
|
257
|
+
version: current.version,
|
|
258
|
+
schema: current.schema,
|
|
259
|
+
schemaHash: current.schemaHash,
|
|
260
|
+
data: draft.data
|
|
261
|
+
};
|
|
262
|
+
const draftVersion = await deps.storage.getVersionById(draft.formVersionId);
|
|
263
|
+
if (draftVersion === void 0) return void 0;
|
|
264
|
+
const changes = diffSchemas(draftVersion.schema, current.schema);
|
|
265
|
+
const severity = changes.reduce((worst, change) => SEVERITY_RANK[change.severity] > SEVERITY_RANK[worst] ? change.severity : worst, "compatible");
|
|
266
|
+
if (severity === "breaking") return {
|
|
267
|
+
outcome: "readOnly",
|
|
268
|
+
version: draftVersion.version,
|
|
269
|
+
schema: draftVersion.schema,
|
|
270
|
+
data: draft.data
|
|
271
|
+
};
|
|
272
|
+
const migrated = migrateDraftData(draft.data, current.schema, changes);
|
|
273
|
+
await deps.storage.upsertDraft({
|
|
274
|
+
id: draft.id,
|
|
275
|
+
formId: draft.formId,
|
|
276
|
+
formVersionId: current.versionId,
|
|
277
|
+
data: migrated,
|
|
278
|
+
updatedAt: deps.nowIso()
|
|
279
|
+
});
|
|
280
|
+
return {
|
|
281
|
+
outcome: "resumed",
|
|
282
|
+
version: current.version,
|
|
283
|
+
schema: current.schema,
|
|
284
|
+
schemaHash: current.schemaHash,
|
|
285
|
+
data: migrated,
|
|
286
|
+
...severity === "lossy" ? { migration: {
|
|
287
|
+
severity,
|
|
288
|
+
changes
|
|
289
|
+
} } : {}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const SEVERITY_RANK = {
|
|
293
|
+
compatible: 0,
|
|
294
|
+
lossy: 1,
|
|
295
|
+
breaking: 2
|
|
296
|
+
};
|
|
297
|
+
const FORMULA_STARTERS = /* @__PURE__ */ new Set([
|
|
298
|
+
"=",
|
|
299
|
+
"+",
|
|
300
|
+
"-",
|
|
301
|
+
"@",
|
|
302
|
+
String.fromCharCode(9),
|
|
303
|
+
String.fromCharCode(13)
|
|
304
|
+
]);
|
|
305
|
+
function migrateDraftData(data, currentSchema, changes) {
|
|
306
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return data;
|
|
307
|
+
const migrated = structuredCloneJson(data);
|
|
308
|
+
applyRenames(currentSchema.model.fields, migrated);
|
|
309
|
+
const orphaned = {};
|
|
310
|
+
for (const change of changes) {
|
|
311
|
+
if (change.kind !== "field.removed") continue;
|
|
312
|
+
const dataPath = change.path.replace(/^model\.fields\./, "");
|
|
313
|
+
if (dataPath.includes("[]")) continue;
|
|
314
|
+
const value = takeAtDottedPath(migrated, dataPath);
|
|
315
|
+
if (value !== void 0) orphaned[dataPath] = value;
|
|
316
|
+
}
|
|
317
|
+
if (Object.keys(orphaned).length > 0) migrated["__orphaned"] = {
|
|
318
|
+
...migrated["__orphaned"],
|
|
319
|
+
...orphaned
|
|
320
|
+
};
|
|
321
|
+
return migrated;
|
|
322
|
+
}
|
|
323
|
+
function applyRenames(fields, data) {
|
|
324
|
+
for (const field of fields) {
|
|
325
|
+
if (field.type === "page") {
|
|
326
|
+
applyRenames(field.fields ?? [], data);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (field.renamedFrom !== void 0 && !(field.key in data) && field.renamedFrom in data) {
|
|
330
|
+
data[field.key] = data[field.renamedFrom];
|
|
331
|
+
delete data[field.renamedFrom];
|
|
332
|
+
}
|
|
333
|
+
if (field.type === "group" && typeof data[field.key] === "object" && data[field.key] !== null) applyRenames(field.fields ?? [], data[field.key]);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Remove and return the value at a dotted path, pruning nothing else. */
|
|
337
|
+
function takeAtDottedPath(data, dottedPath) {
|
|
338
|
+
const segments = dottedPath.split(".");
|
|
339
|
+
let parent = data;
|
|
340
|
+
for (const segment of segments.slice(0, -1)) {
|
|
341
|
+
const next = parent[segment];
|
|
342
|
+
if (next === null || typeof next !== "object" || Array.isArray(next)) return void 0;
|
|
343
|
+
parent = next;
|
|
344
|
+
}
|
|
345
|
+
const last = segments[segments.length - 1];
|
|
346
|
+
const value = parent[last];
|
|
347
|
+
delete parent[last];
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
/** JSON-safe deep clone: draft data is JSON by construction, and this package
|
|
351
|
+
* runs without structuredClone in its lib on purpose. */
|
|
352
|
+
function structuredCloneJson(value) {
|
|
353
|
+
return JSON.parse(JSON.stringify(value));
|
|
354
|
+
}
|
|
355
|
+
/** Every form with its current version — the admin's landing view. */
|
|
356
|
+
async function listForms(deps) {
|
|
357
|
+
const forms = await deps.storage.listForms();
|
|
358
|
+
const listed = [];
|
|
359
|
+
for (const form of forms) {
|
|
360
|
+
if (form.currentVersionId === null) continue;
|
|
361
|
+
const current = await deps.storage.getVersionById(form.currentVersionId);
|
|
362
|
+
if (current === void 0) continue;
|
|
363
|
+
listed.push({
|
|
364
|
+
path: form.path,
|
|
365
|
+
title: current.schema.title,
|
|
366
|
+
version: current.version,
|
|
367
|
+
schemaHash: current.schemaHash
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return listed;
|
|
371
|
+
}
|
|
372
|
+
/** A form's version history, newest first — metadata only, because a history
|
|
373
|
+
* view rendering fifty full schemas would be its own denial of service. */
|
|
374
|
+
async function listVersions(deps, path) {
|
|
375
|
+
const form = await deps.storage.getFormByPath(path);
|
|
376
|
+
if (form === void 0) return void 0;
|
|
377
|
+
return (await deps.storage.listVersionsByForm(form.id)).map((version) => ({
|
|
378
|
+
version: version.version,
|
|
379
|
+
schemaHash: version.schemaHash,
|
|
380
|
+
title: version.schema.title
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/testing/memory-storage.ts
|
|
385
|
+
/**
|
|
386
|
+
* The in-memory Storage — the second implementation that keeps the port
|
|
387
|
+
* honest, and the one unit tests run against. Deliberately dumb: Maps and
|
|
388
|
+
* scans, no cleverness to accidentally diverge from SQL semantics.
|
|
389
|
+
*/
|
|
390
|
+
function createMemoryStorage() {
|
|
391
|
+
const forms = /* @__PURE__ */ new Map();
|
|
392
|
+
const versions = /* @__PURE__ */ new Map();
|
|
393
|
+
const submissions = [];
|
|
394
|
+
const drafts = /* @__PURE__ */ new Map();
|
|
395
|
+
const users = /* @__PURE__ */ new Map();
|
|
396
|
+
const apiKeys = /* @__PURE__ */ new Map();
|
|
397
|
+
return {
|
|
398
|
+
getFormByPath: async (path) => [...forms.values()].find((form) => form.path === path),
|
|
399
|
+
listForms: async () => [...forms.values()].map((form) => ({ ...form })),
|
|
400
|
+
createForm: async (record) => {
|
|
401
|
+
forms.set(record.id, { ...record });
|
|
402
|
+
},
|
|
403
|
+
setCurrentVersion: async (formId, versionId) => {
|
|
404
|
+
const form = forms.get(formId);
|
|
405
|
+
if (form === void 0) throw new Error(`No form "${formId}"`);
|
|
406
|
+
form.currentVersionId = versionId;
|
|
407
|
+
},
|
|
408
|
+
insertVersion: async (record) => {
|
|
409
|
+
versions.set(record.id, { ...record });
|
|
410
|
+
},
|
|
411
|
+
getVersionById: async (id) => versions.get(id),
|
|
412
|
+
findVersionByHash: async (formId, hash) => [...versions.values()].find((version) => version.formId === formId && version.schemaHash === hash),
|
|
413
|
+
latestVersionNumber: async (formId) => Math.max(0, ...[...versions.values()].filter((version) => version.formId === formId).map((version) => version.version)),
|
|
414
|
+
insertSubmission: async (record) => {
|
|
415
|
+
submissions.push({ ...record });
|
|
416
|
+
},
|
|
417
|
+
listSubmissions: async () => [...submissions],
|
|
418
|
+
listSubmissionsByForm: async (formId) => submissions.filter((record) => record.formId === formId).reverse(),
|
|
419
|
+
listVersionsByForm: async (formId) => [...versions.values()].filter((version) => version.formId === formId).sort((a, b) => b.version - a.version),
|
|
420
|
+
upsertDraft: async (record) => {
|
|
421
|
+
drafts.set(record.formId + ":" + record.id, { ...record });
|
|
422
|
+
},
|
|
423
|
+
getDraft: async (formId, draftId) => drafts.get(formId + ":" + draftId),
|
|
424
|
+
insertUser: async (record) => {
|
|
425
|
+
users.set(record.id, { ...record });
|
|
426
|
+
},
|
|
427
|
+
getUserByEmail: async (email) => [...users.values()].find((user) => user.email === email),
|
|
428
|
+
insertApiKey: async (record) => {
|
|
429
|
+
apiKeys.set(record.id, { ...record });
|
|
430
|
+
},
|
|
431
|
+
listApiKeys: async () => [...apiKeys.values()].map((key) => ({ ...key })),
|
|
432
|
+
findApiKeysByPrefix: async (prefix) => [...apiKeys.values()].filter((key) => key.prefix === prefix),
|
|
433
|
+
revokeApiKey: async (id, atIso) => {
|
|
434
|
+
const key = apiKeys.get(id);
|
|
435
|
+
if (key !== void 0) key.revokedAt = atIso;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region src/auth.ts
|
|
441
|
+
const MIN_PASSWORD_LENGTH = 10;
|
|
442
|
+
async function createLocalUser(deps, input) {
|
|
443
|
+
if (input.password.length < MIN_PASSWORD_LENGTH) return {
|
|
444
|
+
ok: false,
|
|
445
|
+
message: `A password needs at least ${MIN_PASSWORD_LENGTH} characters.`
|
|
446
|
+
};
|
|
447
|
+
if (await deps.storage.getUserByEmail(input.email) !== void 0) return {
|
|
448
|
+
ok: false,
|
|
449
|
+
message: "That email is already in use."
|
|
450
|
+
};
|
|
451
|
+
const id = deps.newId();
|
|
452
|
+
await deps.storage.insertUser({
|
|
453
|
+
id,
|
|
454
|
+
email: input.email,
|
|
455
|
+
passwordHash: await deps.hashSecret(input.password),
|
|
456
|
+
role: input.role,
|
|
457
|
+
createdAt: deps.nowIso()
|
|
458
|
+
});
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
id
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* A wrong password and an unknown email answer identically, and BOTH run a
|
|
466
|
+
* hash verification — the difference in shape or timing would be a
|
|
467
|
+
* user-enumeration oracle.
|
|
468
|
+
*/
|
|
469
|
+
async function authenticateLocal(deps, input) {
|
|
470
|
+
const user = await deps.storage.getUserByEmail(input.email);
|
|
471
|
+
const hash = user?.passwordHash ?? await deps.hashSecret(`decoy:${deps.newId()}`);
|
|
472
|
+
const verified = await deps.verifySecret(input.password, hash);
|
|
473
|
+
if (user === void 0 || !verified) return { ok: false };
|
|
474
|
+
return {
|
|
475
|
+
ok: true,
|
|
476
|
+
actor: {
|
|
477
|
+
kind: "user",
|
|
478
|
+
id: user.id,
|
|
479
|
+
role: user.role
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/** The visible prefix of an API key: enough to index and to name in a UI
|
|
484
|
+
* ("fmc_tok_a2c…"), never enough to authenticate. */
|
|
485
|
+
const KEY_PREFIX_LENGTH = 12;
|
|
486
|
+
async function createApiKey(deps, input) {
|
|
487
|
+
if (input.name.trim() === "") return {
|
|
488
|
+
ok: false,
|
|
489
|
+
message: "An API key needs a name."
|
|
490
|
+
};
|
|
491
|
+
const secret = `fmc_${deps.randomToken()}`;
|
|
492
|
+
const id = deps.newId();
|
|
493
|
+
await deps.storage.insertApiKey({
|
|
494
|
+
id,
|
|
495
|
+
name: input.name,
|
|
496
|
+
prefix: secret.slice(0, KEY_PREFIX_LENGTH),
|
|
497
|
+
secretHash: await deps.hashSecret(secret),
|
|
498
|
+
role: input.role,
|
|
499
|
+
createdAt: deps.nowIso(),
|
|
500
|
+
revokedAt: null
|
|
501
|
+
});
|
|
502
|
+
return {
|
|
503
|
+
ok: true,
|
|
504
|
+
id,
|
|
505
|
+
secret
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
async function authenticateApiKey(deps, presented) {
|
|
509
|
+
const candidates = await deps.storage.findApiKeysByPrefix(presented.slice(0, KEY_PREFIX_LENGTH));
|
|
510
|
+
for (const candidate of candidates) {
|
|
511
|
+
if (candidate.revokedAt !== null) continue;
|
|
512
|
+
if (await deps.verifySecret(presented, candidate.secretHash)) return {
|
|
513
|
+
ok: true,
|
|
514
|
+
actor: {
|
|
515
|
+
kind: "apiKey",
|
|
516
|
+
id: candidate.id,
|
|
517
|
+
role: candidate.role
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return { ok: false };
|
|
522
|
+
}
|
|
523
|
+
const VIEWER_ACTIONS = ["form.read", "submission.read"];
|
|
524
|
+
const EDITOR_ACTIONS = [
|
|
525
|
+
...VIEWER_ACTIONS,
|
|
526
|
+
"form.publish",
|
|
527
|
+
"submission.export",
|
|
528
|
+
"draft.write"
|
|
529
|
+
];
|
|
530
|
+
const ADMIN_ACTIONS = [
|
|
531
|
+
...EDITOR_ACTIONS,
|
|
532
|
+
"user.create",
|
|
533
|
+
"apiKey.create"
|
|
534
|
+
];
|
|
535
|
+
const PERMISSIONS = {
|
|
536
|
+
viewer: new Set(VIEWER_ACTIONS),
|
|
537
|
+
editor: new Set(EDITOR_ACTIONS),
|
|
538
|
+
admin: new Set(ADMIN_ACTIONS)
|
|
539
|
+
};
|
|
540
|
+
function can(actor, action) {
|
|
541
|
+
return PERMISSIONS[actor.role].has(action);
|
|
542
|
+
}
|
|
543
|
+
//#endregion
|
|
544
|
+
export { authenticateApiKey, authenticateLocal, can, createApiKey, createLocalUser, createMemoryStorage, createSubmission, exportCsv, listForms, listSubmissions, listVersions, publishForm, resolveForm, resumeDraft, saveDraft };
|
|
545
|
+
|
|
546
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/use-cases.ts","../src/testing/memory-storage.ts","../src/auth.ts"],"sourcesContent":["import { diffSchemas, schemaHash } from '@formancy/spec'\nimport type { Change, FormSchema } from '@formancy/spec'\nimport { validateSchema } from '@formancy/spec/validate'\nimport type { SchemaError } from '@formancy/spec/validate'\nimport { createFormEngine } from '@formancy/core'\nimport type { CapabilitySource } from '@formancy/core'\nimport type { Storage } from './ports.js'\n\n/**\n * The backend's use-cases, framework-free.\n *\n * Identity and time are injected like every other capability: the server pins\n * one clock per request, so a submission can be replayed later and produce the\n * same computed values byte for byte. Nothing here reads Date.now or crypto.\n */\nexport interface ServerDeps {\n storage: Storage\n newId(): string\n nowIso(): string\n /** The clock/randomness the ENGINE sees during replay. */\n capabilities: CapabilitySource\n}\n\nexport type PublishOutcome =\n | { ok: true; formId: string; versionId: string; version: number; schemaHash: string }\n | { ok: false; kind: 'invalid_schema'; errors?: SchemaError[] }\n | { ok: false; kind: 'invalid_logic'; message: string }\n\n/**\n * Publish a schema as a form's next version.\n *\n * This is the save gate: structural validation, then the ENGINE's own compile\n * — type-checked expressions, kind policies and the cycle graph — so a form\n * that can loop or read a ghost field is refused here and never persisted.\n * Republishing the identical document is idempotent, because \"deploy again\"\n * must never manufacture a version.\n */\nexport async function publishForm(\n deps: ServerDeps,\n input: { path: string; schema: unknown },\n): Promise<PublishOutcome> {\n const validated = validateSchema(input.schema)\n if (!validated.valid) return { ok: false, kind: 'invalid_schema', errors: validated.errors }\n const schema: FormSchema = validated.schema\n\n try {\n createFormEngine({ schema, capabilities: deps.capabilities })\n } catch (error) {\n return {\n ok: false,\n kind: 'invalid_logic',\n message: error instanceof Error ? error.message : String(error),\n }\n }\n\n const hash = schemaHash(schema)\n const existing = await deps.storage.getFormByPath(input.path)\n\n if (existing !== undefined) {\n const current =\n existing.currentVersionId === null\n ? undefined\n : await deps.storage.getVersionById(existing.currentVersionId)\n if (current !== undefined && current.schemaHash === hash) {\n return {\n ok: true,\n formId: existing.id,\n versionId: current.id,\n version: current.version,\n schemaHash: hash,\n }\n }\n\n const version = (await deps.storage.latestVersionNumber(existing.id)) + 1\n const versionId = deps.newId()\n await deps.storage.insertVersion({\n id: versionId,\n formId: existing.id,\n version,\n schema,\n schemaHash: hash,\n })\n await deps.storage.setCurrentVersion(existing.id, versionId)\n return { ok: true, formId: existing.id, versionId, version, schemaHash: hash }\n }\n\n const formId = deps.newId()\n const versionId = deps.newId()\n await deps.storage.createForm({ id: formId, path: input.path, currentVersionId: null })\n await deps.storage.insertVersion({ id: versionId, formId, version: 1, schema, schemaHash: hash })\n await deps.storage.setCurrentVersion(formId, versionId)\n return { ok: true, formId, versionId, version: 1, schemaHash: hash }\n}\n\nexport interface ResolvedForm {\n formId: string\n versionId: string\n version: number\n schema: FormSchema\n schemaHash: string\n}\n\nexport async function resolveForm(deps: ServerDeps, path: string): Promise<ResolvedForm | undefined> {\n const form = await deps.storage.getFormByPath(path)\n if (form === undefined || form.currentVersionId === null) return undefined\n const current = await deps.storage.getVersionById(form.currentVersionId)\n if (current === undefined) return undefined\n return {\n formId: form.id,\n versionId: current.id,\n version: current.version,\n schema: current.schema,\n schemaHash: current.schemaHash,\n }\n}\n\nexport type SubmissionOutcome =\n | { ok: true; id: string; canonicalData: unknown }\n | { ok: false; kind: 'unknown_form' }\n | { ok: false; kind: 'version_changed'; current?: ResolvedForm }\n | { ok: false; kind: 'invalid'; errors: Record<string, string[]> }\n\n/**\n * Accept one submission: resolve the version the client says it rendered,\n * replay the engine over the submitted data, and store the CANONICAL result.\n *\n * The replay is the whole point. Computed values are recomputed and overwrite\n * whatever arrived; visibility is evaluated server-side and hidden branches\n * are stripped, so a client cannot smuggle data by lying about what was shown.\n * Client validation is UX; this is truth.\n */\nexport async function createSubmission(\n deps: ServerDeps,\n input: { path: string; declaredSchemaHash: string; data: unknown },\n): Promise<SubmissionOutcome> {\n const current = await resolveForm(deps, input.path)\n if (current === undefined) return { ok: false, kind: 'unknown_form' }\n\n if (input.declaredSchemaHash !== current.schemaHash) {\n // The thin slice's staleVersionPolicy is `reject`: the acceptCompatible\n // policy arrives with draft migration, driven by diffSchemas severity.\n return { ok: false, kind: 'version_changed', current }\n }\n\n const engine = createFormEngine({\n schema: current.schema,\n initialValue: input.data,\n capabilities: deps.capabilities,\n })\n const outcome = engine.submit()\n if (!outcome.ok) return { ok: false, kind: 'invalid', errors: outcome.errors }\n\n const id = deps.newId()\n await deps.storage.insertSubmission({\n id,\n formId: current.formId,\n formVersionId: current.versionId,\n data: engine.value(),\n submittedAt: deps.nowIso(),\n })\n return { ok: true, id, canonicalData: engine.value() }\n}\n\nexport interface ListedSubmission {\n id: string\n version: number\n submittedAt: string\n data: unknown\n}\n\n/** The submissions of one form, newest first, each tagged with the schema\n * version that produced it. Undefined for an unknown form: the caller must\n * 404, and an empty list would lie about that. */\nexport async function listSubmissions(\n deps: ServerDeps,\n path: string,\n): Promise<ListedSubmission[] | undefined> {\n const form = await deps.storage.getFormByPath(path)\n if (form === undefined) return undefined\n\n const versions = await deps.storage.listVersionsByForm(form.id)\n const versionNumberById = new Map(versions.map((version) => [version.id, version.version]))\n\n const records = await deps.storage.listSubmissionsByForm(form.id)\n return records.map((record) => ({\n id: record.id,\n version: versionNumberById.get(record.formVersionId) ?? 0,\n submittedAt: record.submittedAt,\n data: record.data,\n }))\n}\n\n/**\n * Every submission of a form as CSV, columns UNIONED across schema versions:\n * the current version's columns lead in its own order, and columns that only\n * exist in older versions follow — data outlives the field that collected it,\n * and an export that silently dropped extinct columns would lose it.\n *\n * A repeater is one column carrying its rows as JSON: rows-per-cell beats\n * exploding one submission across several CSV lines, which breaks every\n * spreadsheet join a business user will try.\n */\nexport async function exportCsv(deps: ServerDeps, path: string): Promise<string | undefined> {\n const form = await deps.storage.getFormByPath(path)\n if (form === undefined) return undefined\n\n const versions = await deps.storage.listVersionsByForm(form.id)\n const versionNumberById = new Map(versions.map((version) => [version.id, version.version]))\n\n const columns: string[] = []\n const seen = new Set<string>()\n for (const version of versions) {\n for (const column of dataColumns(version.schema)) {\n if (!seen.has(column)) {\n seen.add(column)\n columns.push(column)\n }\n }\n }\n\n const records = await deps.storage.listSubmissionsByForm(form.id)\n const lines = [['id', 'submittedAt', 'version', ...columns].map(csvCell).join(',')]\n for (const record of records) {\n const cells = [\n record.id,\n record.submittedAt,\n String(versionNumberById.get(record.formVersionId) ?? 0),\n ...columns.map((column) => cellValue(record.data, column)),\n ]\n lines.push(cells.map(csvCell).join(','))\n }\n return lines.join('\\n') + '\\n'\n}\n\n/** One column per collected answer: leaves as dotted paths through groups,\n * a repeater as a single column, pages transparent as always. */\nfunction dataColumns(schema: FormSchema): string[] {\n const columns: string[] = []\n const walk = (fields: readonly FormSchema['model']['fields'][number][], prefix: string): void => {\n for (const field of fields) {\n if (field.type === 'page') walk(field.fields ?? [], prefix)\n else if (field.type === 'group') walk(field.fields ?? [], `${prefix}${field.key}.`)\n else columns.push(`${prefix}${field.key}`)\n }\n }\n walk(schema.model.fields, '')\n return columns\n}\n\nfunction cellValue(data: unknown, column: string): string {\n let current: unknown = data\n for (const segment of column.split('.')) {\n if (current === null || typeof current !== 'object') return ''\n current = (current as Record<string, unknown>)[segment]\n }\n if (current === undefined || current === null) return ''\n if (typeof current === 'object') return JSON.stringify(current)\n if (typeof current === 'string' && FORMULA_STARTERS.has(current.charAt(0))) {\n // Spreadsheets execute a cell that starts like a formula. A submission is\n // attacker-controlled and this file is opened by exactly the person worth\n // attacking, so neutralise with the apostrophe convention. Only strings:\n // a number cannot smuggle a formula, and a quoted -5 would break every\n // numeric column.\n return \"'\" + current\n }\n return String(current)\n}\n\nfunction csvCell(value: string): string {\n return /[\",\\n\\r]/.test(value) ? `\"${value.replaceAll('\"', '\"\"')}\"` : value\n}\n\n/** Save (or re-save) a partial form, bound to the CURRENT version. Undefined\n * for an unknown form. */\nexport async function saveDraft(\n deps: ServerDeps,\n input: { path: string; draftId: string; data: unknown },\n): Promise<{ version: number } | undefined> {\n const current = await resolveForm(deps, input.path)\n if (current === undefined) return undefined\n\n await deps.storage.upsertDraft({\n id: input.draftId,\n formId: current.formId,\n formVersionId: current.versionId,\n data: input.data,\n updatedAt: deps.nowIso(),\n })\n return { version: current.version }\n}\n\nexport interface DraftMigration {\n severity: 'lossy'\n changes: Change[]\n}\n\nexport type ResumeOutcome =\n | {\n outcome: 'resumed'\n version: number\n schema: FormSchema\n schemaHash: string\n data: unknown\n /** Present when the rebind lost something; absent for a silent rebind. */\n migration?: DraftMigration\n }\n | {\n /** The schema changed in a way no automatic rebind survives: the draft\n * comes back against ITS OWN version, and the caller offers a restart. */\n outcome: 'readOnly'\n version: number\n schema: FormSchema\n data: unknown\n }\n\n/**\n * Resume a draft — and migrate it LAZILY if the form was republished since,\n * which is versioning rule five: never migrate eagerly on publish, because\n * most drafts are abandoned and eager migration multiplies every publish by\n * every draft.\n *\n * diffSchemas drives the decision: `compatible` rebinds silently, `lossy`\n * rebinds with a report — declared renames carry their value to the new key,\n * removed fields move their value into `__orphaned` rather than deleting it —\n * and `breaking` refuses to rebind at all. A successful rebind is persisted,\n * so the migration runs once, not on every resume.\n */\nexport async function resumeDraft(\n deps: ServerDeps,\n input: { path: string; draftId: string },\n): Promise<ResumeOutcome | undefined> {\n const current = await resolveForm(deps, input.path)\n if (current === undefined) return undefined\n\n const draft = await deps.storage.getDraft(current.formId, input.draftId)\n if (draft === undefined) return undefined\n\n if (draft.formVersionId === current.versionId) {\n return {\n outcome: 'resumed',\n version: current.version,\n schema: current.schema,\n schemaHash: current.schemaHash,\n data: draft.data,\n }\n }\n\n const draftVersion = await deps.storage.getVersionById(draft.formVersionId)\n if (draftVersion === undefined) return undefined\n\n const changes = diffSchemas(draftVersion.schema, current.schema)\n const severity = changes.reduce<'compatible' | 'lossy' | 'breaking'>(\n (worst, change) =>\n SEVERITY_RANK[change.severity] > SEVERITY_RANK[worst] ? change.severity : worst,\n 'compatible',\n )\n\n if (severity === 'breaking') {\n return {\n outcome: 'readOnly',\n version: draftVersion.version,\n schema: draftVersion.schema,\n data: draft.data,\n }\n }\n\n const migrated = migrateDraftData(draft.data, current.schema, changes)\n\n await deps.storage.upsertDraft({\n id: draft.id,\n formId: draft.formId,\n formVersionId: current.versionId,\n data: migrated,\n updatedAt: deps.nowIso(),\n })\n\n return {\n outcome: 'resumed',\n version: current.version,\n schema: current.schema,\n schemaHash: current.schemaHash,\n data: migrated,\n ...(severity === 'lossy' ? { migration: { severity, changes } } : {}),\n }\n}\n\nconst SEVERITY_RANK = { compatible: 0, lossy: 1, breaking: 2 } as const\n\nconst FORMULA_STARTERS = new Set(['=', '+', '-', '@', String.fromCharCode(9), String.fromCharCode(13)])\n\nfunction migrateDraftData(data: unknown, currentSchema: FormSchema, changes: Change[]): unknown {\n if (data === null || typeof data !== 'object' || Array.isArray(data)) return data\n const migrated: Record<string, unknown> = structuredCloneJson(data as Record<string, unknown>)\n\n // Declared renames first: the value follows the field to its new key.\n applyRenames(currentSchema.model.fields, migrated)\n\n // Then removals: the value moves aside instead of vanishing. Only top-level\n // and group paths move; row members stay inside their (possibly orphaned)\n // repeater value, which travels as a whole.\n const orphaned: Record<string, unknown> = {}\n for (const change of changes) {\n if (change.kind !== 'field.removed') continue\n const dataPath = change.path.replace(/^model\\.fields\\./, '')\n if (dataPath.includes('[]')) continue\n const value = takeAtDottedPath(migrated, dataPath)\n if (value !== undefined) orphaned[dataPath] = value\n }\n if (Object.keys(orphaned).length > 0) {\n migrated['__orphaned'] = {\n ...(migrated['__orphaned'] as Record<string, unknown> | undefined),\n ...orphaned,\n }\n }\n return migrated\n}\n\nfunction applyRenames(fields: FormSchema['model']['fields'], data: Record<string, unknown>): void {\n for (const field of fields) {\n if (field.type === 'page') {\n applyRenames(field.fields ?? [], data)\n continue\n }\n if (field.renamedFrom !== undefined && !(field.key in data) && field.renamedFrom in data) {\n data[field.key] = data[field.renamedFrom]\n delete data[field.renamedFrom]\n }\n if (field.type === 'group' && typeof data[field.key] === 'object' && data[field.key] !== null) {\n applyRenames(field.fields ?? [], data[field.key] as Record<string, unknown>)\n }\n }\n}\n\n/** Remove and return the value at a dotted path, pruning nothing else. */\nfunction takeAtDottedPath(data: Record<string, unknown>, dottedPath: string): unknown {\n const segments = dottedPath.split('.')\n let parent: Record<string, unknown> = data\n for (const segment of segments.slice(0, -1)) {\n const next = parent[segment]\n if (next === null || typeof next !== 'object' || Array.isArray(next)) return undefined\n parent = next as Record<string, unknown>\n }\n const last = segments[segments.length - 1]!\n const value = parent[last]\n delete parent[last]\n return value\n}\n\n/** JSON-safe deep clone: draft data is JSON by construction, and this package\n * runs without structuredClone in its lib on purpose. */\nfunction structuredCloneJson<T>(value: T): T {\n return JSON.parse(JSON.stringify(value)) as T\n}\n\nexport interface ListedForm {\n path: string\n title: string\n version: number\n schemaHash: string\n}\n\n/** Every form with its current version — the admin's landing view. */\nexport async function listForms(deps: ServerDeps): Promise<ListedForm[]> {\n const forms = await deps.storage.listForms()\n const listed: ListedForm[] = []\n for (const form of forms) {\n if (form.currentVersionId === null) continue\n const current = await deps.storage.getVersionById(form.currentVersionId)\n if (current === undefined) continue\n listed.push({\n path: form.path,\n title: current.schema.title,\n version: current.version,\n schemaHash: current.schemaHash,\n })\n }\n return listed\n}\n\nexport interface ListedVersion {\n version: number\n schemaHash: string\n title: string\n}\n\n/** A form's version history, newest first — metadata only, because a history\n * view rendering fifty full schemas would be its own denial of service. */\nexport async function listVersions(\n deps: ServerDeps,\n path: string,\n): Promise<ListedVersion[] | undefined> {\n const form = await deps.storage.getFormByPath(path)\n if (form === undefined) return undefined\n const versions = await deps.storage.listVersionsByForm(form.id)\n return versions.map((version) => ({\n version: version.version,\n schemaHash: version.schemaHash,\n title: version.schema.title,\n }))\n}\n","import type { ApiKeyRecord, DraftRecord, FormRecord, FormVersionRecord, Storage, SubmissionRecord, UserRecord } from '../ports.js'\n\n/**\n * The in-memory Storage — the second implementation that keeps the port\n * honest, and the one unit tests run against. Deliberately dumb: Maps and\n * scans, no cleverness to accidentally diverge from SQL semantics.\n */\nexport function createMemoryStorage(): Storage {\n const forms = new Map<string, FormRecord>()\n const versions = new Map<string, FormVersionRecord>()\n const submissions: SubmissionRecord[] = []\n const drafts = new Map<string, DraftRecord>()\n const users = new Map<string, UserRecord>()\n const apiKeys = new Map<string, ApiKeyRecord>()\n\n return {\n getFormByPath: async (path) => [...forms.values()].find((form) => form.path === path),\n\n listForms: async () => [...forms.values()].map((form) => ({ ...form })),\n\n createForm: async (record) => {\n forms.set(record.id, { ...record })\n },\n\n setCurrentVersion: async (formId, versionId) => {\n const form = forms.get(formId)\n if (form === undefined) throw new Error(`No form \"${formId}\"`)\n form.currentVersionId = versionId\n },\n\n insertVersion: async (record) => {\n versions.set(record.id, { ...record })\n },\n\n getVersionById: async (id) => versions.get(id),\n\n findVersionByHash: async (formId, hash) =>\n [...versions.values()].find(\n (version) => version.formId === formId && version.schemaHash === hash,\n ),\n\n latestVersionNumber: async (formId) =>\n Math.max(\n 0,\n ...[...versions.values()]\n .filter((version) => version.formId === formId)\n .map((version) => version.version),\n ),\n\n insertSubmission: async (record) => {\n submissions.push({ ...record })\n },\n\n listSubmissions: async () => [...submissions],\n\n listSubmissionsByForm: async (formId) =>\n submissions.filter((record) => record.formId === formId).reverse(),\n\n listVersionsByForm: async (formId) =>\n [...versions.values()]\n .filter((version) => version.formId === formId)\n .sort((a, b) => b.version - a.version),\n\n upsertDraft: async (record) => {\n drafts.set(record.formId + ':' + record.id, { ...record })\n },\n\n getDraft: async (formId, draftId) => drafts.get(formId + ':' + draftId),\n\n insertUser: async (record) => {\n users.set(record.id, { ...record })\n },\n\n getUserByEmail: async (email) =>\n [...users.values()].find((user) => user.email === email),\n\n insertApiKey: async (record) => {\n apiKeys.set(record.id, { ...record })\n },\n\n listApiKeys: async () => [...apiKeys.values()].map((key) => ({ ...key })),\n\n findApiKeysByPrefix: async (prefix) =>\n [...apiKeys.values()].filter((key) => key.prefix === prefix),\n\n revokeApiKey: async (id, atIso) => {\n const key = apiKeys.get(id)\n if (key !== undefined) key.revokedAt = atIso\n },\n }\n}\n","import type { Storage } from './ports.js'\n\n/**\n * The management plane's identity and authorization, framework-free.\n *\n * Hashing and randomness are injected — this package cannot reach a CSPRNG or\n * argon2 on its own, the composition root hands them in — which keeps every\n * behaviour here testable with deterministic fakes while the real server gets\n * real cryptography.\n */\nexport type Role = 'admin' | 'editor' | 'viewer'\n\nexport interface Actor {\n kind: 'user' | 'apiKey'\n id: string\n role: Role\n}\n\nexport interface AuthDeps {\n storage: Storage\n newId(): string\n nowIso(): string\n /** At least 160 bits of entropy, URL-safe. Injected, never Math.random. */\n randomToken(): string\n hashSecret(secret: string): Promise<string>\n verifySecret(secret: string, hash: string): Promise<boolean>\n}\n\nconst MIN_PASSWORD_LENGTH = 10\n\nexport type CreateUserOutcome =\n | { ok: true; id: string }\n | { ok: false; message: string }\n\nexport async function createLocalUser(\n deps: AuthDeps,\n input: { email: string; password: string; role: Role },\n): Promise<CreateUserOutcome> {\n if (input.password.length < MIN_PASSWORD_LENGTH) {\n return { ok: false, message: `A password needs at least ${MIN_PASSWORD_LENGTH} characters.` }\n }\n if ((await deps.storage.getUserByEmail(input.email)) !== undefined) {\n return { ok: false, message: 'That email is already in use.' }\n }\n\n const id = deps.newId()\n await deps.storage.insertUser({\n id,\n email: input.email,\n passwordHash: await deps.hashSecret(input.password),\n role: input.role,\n createdAt: deps.nowIso(),\n })\n return { ok: true, id }\n}\n\nexport type AuthOutcome = { ok: true; actor: Actor } | { ok: false }\n\n/**\n * A wrong password and an unknown email answer identically, and BOTH run a\n * hash verification — the difference in shape or timing would be a\n * user-enumeration oracle.\n */\nexport async function authenticateLocal(\n deps: AuthDeps,\n input: { email: string; password: string },\n): Promise<AuthOutcome> {\n const user = await deps.storage.getUserByEmail(input.email)\n const hash = user?.passwordHash ?? (await deps.hashSecret(`decoy:${deps.newId()}`))\n const verified = await deps.verifySecret(input.password, hash)\n if (user === undefined || !verified) return { ok: false }\n return { ok: true, actor: { kind: 'user', id: user.id, role: user.role } }\n}\n\n/** The visible prefix of an API key: enough to index and to name in a UI\n * (\"fmc_tok_a2c…\"), never enough to authenticate. */\nconst KEY_PREFIX_LENGTH = 12\n\nexport type CreateApiKeyOutcome =\n | { ok: true; id: string; secret: string }\n | { ok: false; message: string }\n\nexport async function createApiKey(\n deps: AuthDeps,\n input: { name: string; role: Role },\n): Promise<CreateApiKeyOutcome> {\n if (input.name.trim() === '') return { ok: false, message: 'An API key needs a name.' }\n\n // The secret exists in full exactly once — in this return value. Storage\n // holds the prefix (for lookup and display) and a hash (for verification).\n const secret = `fmc_${deps.randomToken()}`\n const id = deps.newId()\n await deps.storage.insertApiKey({\n id,\n name: input.name,\n prefix: secret.slice(0, KEY_PREFIX_LENGTH),\n secretHash: await deps.hashSecret(secret),\n role: input.role,\n createdAt: deps.nowIso(),\n revokedAt: null,\n })\n return { ok: true, id, secret }\n}\n\nexport async function authenticateApiKey(deps: AuthDeps, presented: string): Promise<AuthOutcome> {\n const candidates = await deps.storage.findApiKeysByPrefix(presented.slice(0, KEY_PREFIX_LENGTH))\n for (const candidate of candidates) {\n if (candidate.revokedAt !== null) continue\n if (await deps.verifySecret(presented, candidate.secretHash)) {\n return { ok: true, actor: { kind: 'apiKey', id: candidate.id, role: candidate.role } }\n }\n }\n return { ok: false }\n}\n\n/**\n * The whole authorization model, in one table a reviewer can read top to\n * bottom. Deliberately NOT Postgres RLS in this phase: RLS couples the app to\n * per-request database roles and hides authorization from the test suite.\n * An unknown action is denied — permissions are granted by presence, never by\n * omission.\n */\nexport type Action =\n | 'form.read'\n | 'form.publish'\n | 'submission.read'\n | 'submission.export'\n | 'draft.write'\n | 'user.create'\n | 'apiKey.create'\n\nconst VIEWER_ACTIONS: readonly Action[] = ['form.read', 'submission.read']\nconst EDITOR_ACTIONS: readonly Action[] = [\n ...VIEWER_ACTIONS,\n 'form.publish',\n 'submission.export',\n 'draft.write',\n]\nconst ADMIN_ACTIONS: readonly Action[] = [...EDITOR_ACTIONS, 'user.create', 'apiKey.create']\n\nconst PERMISSIONS: Record<Role, ReadonlySet<Action>> = {\n viewer: new Set(VIEWER_ACTIONS),\n editor: new Set(EDITOR_ACTIONS),\n admin: new Set(ADMIN_ACTIONS),\n}\n\nexport function can(actor: Actor, action: Action): boolean {\n return PERMISSIONS[actor.role].has(action)\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@formancy/server-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The formancy backend's use-cases: publish, resolve, revalidate, store — framework-free, against repository ports.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
11
|
+
"default": "./dist/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"NOTICE"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@formancy/core": "0.1.0",
|
|
21
|
+
"@formancy/spec": "0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
25
|
+
"publint": "^0.3.24",
|
|
26
|
+
"tsdown": "^0.23.0",
|
|
27
|
+
"typescript": "~6.0.3",
|
|
28
|
+
"unrun": "^0.3.1",
|
|
29
|
+
"vitest": "^5.0.1"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/sharkysan/formancy.ai.git",
|
|
37
|
+
"directory": "packages/server-core"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/sharkysan/formancy.ai",
|
|
40
|
+
"bugs": "https://github.com/sharkysan/formancy.ai/issues",
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"check:pkg": "publint && attw --pack . --profile esm-only"
|
|
46
|
+
}
|
|
47
|
+
}
|