ignotum 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +8 -3
  2. package/dist/cli/bin.mjs +1372 -492
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-DzcR7spt.js → api-CAgKDij7.js} +38 -2
  5. package/dist/runtime/api-CAgKDij7.js.map +1 -0
  6. package/dist/runtime/{api-DR_8vfKg.d.ts → api-Cv3hMbzo.d.ts} +4 -3
  7. package/dist/runtime/client.d.ts +39 -8
  8. package/dist/runtime/client.js +505 -131
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js +154 -0
  11. package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js.map +1 -0
  12. package/dist/runtime/id-Bt9XWRGL.js +423 -0
  13. package/dist/runtime/id-Bt9XWRGL.js.map +1 -0
  14. package/dist/runtime/{id-Cs82tq9Q-hmYFyqTa.d.ts → id-BzFHf3Wo-DGPCjgrf.d.ts} +13 -13
  15. package/dist/runtime/id-Dz0apuB3.d.ts +1 -0
  16. package/dist/runtime/{index-2q9FwJud.d.ts → index-D54flWtH.d.ts} +136 -76
  17. package/dist/runtime/internal/api.d.ts +1 -1
  18. package/dist/runtime/internal/api.js +1 -1
  19. package/dist/runtime/internal/host.d.ts +21 -17
  20. package/dist/runtime/internal/host.js +22 -8
  21. package/dist/runtime/internal/host.js.map +1 -1
  22. package/dist/runtime/internal/server.d.ts +1 -1
  23. package/dist/runtime/internal/server.js +1 -1
  24. package/dist/runtime/internal/types.d.ts +1 -1
  25. package/dist/runtime/internal/types.js +1 -1
  26. package/dist/runtime/{pagination-Bt3l7QaC-BYEGmLBE.d.ts → pagination-DnKg3dkI-r5ZUxBBx.d.ts} +34 -6
  27. package/dist/runtime/pagination-Dz0apuB3.d.ts +1 -0
  28. package/dist/runtime/result-DKAA4gpS.d.ts +1 -0
  29. package/dist/runtime/{schema-ERFjT8-m.js → schema-1Zs03-iS.js} +135 -17
  30. package/dist/runtime/schema-1Zs03-iS.js.map +1 -0
  31. package/dist/runtime/server.d.ts +9 -3
  32. package/dist/runtime/server.js +5 -3
  33. package/dist/runtime/server.js.map +1 -1
  34. package/dist/runtime/{sync-a2EdGSdY.d.ts → sync-Bs8J3fIr.d.ts} +2 -2
  35. package/package.json +4 -4
  36. package/src/cli/agent-files.ts +8 -0
  37. package/src/cli/auth-client.ts +1 -1
  38. package/src/cli/bin.ts +13 -3
  39. package/src/cli/build/server.ts +102 -13
  40. package/src/cli/codegen.ts +7 -4
  41. package/src/cli/control-client.ts +30 -8
  42. package/src/cli/deploy.ts +7 -2
  43. package/src/cli/environment.ts +133 -0
  44. package/src/client/files.ts +43 -27
  45. package/src/client/hooks.ts +94 -17
  46. package/src/client/id.ts +259 -0
  47. package/src/client/index.ts +5 -2
  48. package/src/client/page-observers.ts +77 -0
  49. package/src/client/sync.ts +214 -69
  50. package/src/dev-runtime/functions.ts +232 -86
  51. package/src/dev-runtime/id.ts +174 -2
  52. package/src/dev-runtime/query-cache.ts +105 -0
  53. package/src/dev-runtime/sync.ts +187 -26
  54. package/src/server/index.ts +13 -1
  55. package/dist/runtime/api-DzcR7spt.js.map +0 -1
  56. package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js +0 -311
  57. package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js.map +0 -1
  58. package/dist/runtime/file-C1abuMgd.js +0 -173
  59. package/dist/runtime/file-C1abuMgd.js.map +0 -1
  60. package/dist/runtime/pagination-CFJ3xlAt.d.ts +0 -1
  61. package/dist/runtime/schema-ERFjT8-m.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { makeIdContext, type IdContext, type SessionState } from "@ignotum/contracts/id";
1
2
  import {
2
3
  FunctionUnavailable,
3
4
  InvalidArguments,
@@ -6,12 +7,21 @@ import {
6
7
  type FunctionKind,
7
8
  type FunctionResolutionError,
8
9
  } from "@ignotum/contracts/runtime/functions";
9
- import { type RuntimeSchemaDefinition } from "@ignotum/contracts/runtime/schema";
10
+ import {
11
+ getFunctionEnvironment,
12
+ type RuntimeSchemaDefinition,
13
+ } from "@ignotum/contracts/runtime/schema";
10
14
  import { ErrorValueSchema, type ErrorValue } from "@ignotum/contracts/runtime/result";
11
15
  import { RequestId } from "@ignotum/contracts/runtime/id";
12
16
  import { IdGenerator } from "@ignotum/shared/id";
13
17
  import { encodedFileOccurrencesOf } from "@ignotum/contracts/schema/file";
14
18
  import type { RuntimeInvocationResult } from "@ignotum/contracts/runtime/hosted";
19
+ import {
20
+ emptyEnv,
21
+ isDefinedEnv,
22
+ type DefinedEnv,
23
+ type EnvironmentType,
24
+ } from "@ignotum/contracts/schema";
15
25
  import {
16
26
  FunctionAddress,
17
27
  apiFunctionParts,
@@ -21,7 +31,19 @@ import {
21
31
  type WireResult,
22
32
  } from "@ignotum/contracts/runtime/sync";
23
33
  import { FunctionRuntime } from "@ignotum/runtime/functions";
24
- import { Cause, Context, Effect, Layer, Path, Predicate, Schema, Semaphore } from "effect";
34
+ import {
35
+ Cause,
36
+ Context,
37
+ Effect,
38
+ FileSystem,
39
+ Layer,
40
+ Path,
41
+ Predicate,
42
+ Ref,
43
+ Result,
44
+ Schema,
45
+ Semaphore,
46
+ } from "effect";
25
47
  import type { ViteDevServer } from "vite";
26
48
  import { normalizePath } from "vite";
27
49
 
@@ -31,17 +53,92 @@ import {
31
53
  type FunctionArguments,
32
54
  type RuntimeFunctionDefinition as InternalRuntimeFunctionDefinition,
33
55
  } from "../internal/function-definition.js";
56
+ import {
57
+ EnvironmentConfigurationInvalid,
58
+ loadEnvironment,
59
+ type LoadedEnvironment,
60
+ } from "../cli/environment.js";
34
61
 
35
62
  export { FunctionUnavailable, InvalidArguments, UnknownFunction, WrongFunctionKind };
36
63
  export type { FunctionArguments, FunctionKind };
37
64
 
38
- export type RuntimeHandlerContext = LocalMutationContext | LocalQueryContext;
65
+ export type RuntimeHandlerContext = (LocalMutationContext | LocalQueryContext) & {
66
+ readonly id: IdContext;
67
+ };
39
68
  export type RuntimeFunctionDefinition = InternalRuntimeFunctionDefinition<RuntimeHandlerContext>;
40
69
 
70
+ const loadDevelopmentEnvironment = Effect.fn("DevelopmentEnvironment.load")(function* (
71
+ server: ViteDevServer,
72
+ appDirectory: string,
73
+ ) {
74
+ const fileSystem = yield* FileSystem.FileSystem;
75
+ const path = yield* Path.Path;
76
+ const definitionPath = path.join(appDirectory, "server", "env.ts");
77
+ const hasDefinition = yield* fileSystem.exists(definitionPath);
78
+ if (!hasDefinition) return yield* loadEnvironment(appDirectory, emptyEnv, false);
79
+ const loaded = yield* Effect.tryPromise({
80
+ try: () => server.ssrLoadModule(normalizePath(definitionPath)),
81
+ catch: () =>
82
+ EnvironmentConfigurationInvalid.make({
83
+ message: "Ignotum could not load server/env.ts.",
84
+ path: definitionPath,
85
+ }),
86
+ });
87
+ if (!Predicate.isObject(loaded.default) || !isDefinedEnv(loaded.default)) {
88
+ return yield* EnvironmentConfigurationInvalid.make({
89
+ message: "The default export from server/env.ts is not an Ignotum environment.",
90
+ path: definitionPath,
91
+ });
92
+ }
93
+ return yield* loadEnvironment(appDirectory, loaded.default, true);
94
+ });
95
+
96
+ export class DevelopmentEnvironment extends Context.Service<
97
+ DevelopmentEnvironment,
98
+ {
99
+ readonly reload: Effect.Effect<void, unknown>;
100
+ readonly snapshot: Effect.Effect<LoadedEnvironment, unknown>;
101
+ }
102
+ >()("ignotum/dev-runtime/functions/DevelopmentEnvironment") {
103
+ static layer(server: ViteDevServer, appDirectory: string) {
104
+ return Layer.effect(
105
+ DevelopmentEnvironment,
106
+ Effect.gen(function* () {
107
+ const fileSystem = yield* FileSystem.FileSystem;
108
+ const path = yield* Path.Path;
109
+ const load = loadDevelopmentEnvironment(server, appDirectory).pipe(
110
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
111
+ Effect.provideService(Path.Path, path),
112
+ );
113
+ const initial = yield* load;
114
+ const current = yield* Ref.make<Result.Result<LoadedEnvironment, unknown>>(
115
+ Result.succeed(initial),
116
+ );
117
+ const fromResult = Result.match({
118
+ onFailure: Effect.fail,
119
+ onSuccess: Effect.succeed,
120
+ });
121
+ const reload = load.pipe(
122
+ Effect.result,
123
+ Effect.flatMap((result) =>
124
+ Ref.set(current, result).pipe(Effect.andThen(fromResult(result))),
125
+ ),
126
+ Effect.asVoid,
127
+ );
128
+ return DevelopmentEnvironment.of({
129
+ reload,
130
+ snapshot: Ref.get(current).pipe(Effect.flatMap(fromResult)),
131
+ });
132
+ }),
133
+ );
134
+ }
135
+ }
136
+
41
137
  export interface ResolvedFunction {
42
138
  readonly definition: RuntimeFunctionDefinition;
43
139
  readonly args: FunctionArguments;
44
140
  readonly schema: RuntimeSchemaDefinition;
141
+ readonly environment?: Readonly<EnvironmentType<DefinedEnv>>;
45
142
  }
46
143
 
47
144
  export class FunctionRegistry extends Context.Service<
@@ -59,6 +156,7 @@ export class FunctionRegistry extends Context.Service<
59
156
  FunctionRegistry,
60
157
  Effect.gen(function* () {
61
158
  const path = yield* Path.Path;
159
+ const developmentEnvironment = yield* DevelopmentEnvironment;
62
160
  return FunctionRegistry.of({
63
161
  resolve: Effect.fn("FunctionRegistry.resolve")(function* (functionAddress, kind, args) {
64
162
  const { functionName, moduleName } = apiFunctionParts(functionAddress);
@@ -84,6 +182,14 @@ export class FunctionRegistry extends Context.Service<
84
182
  });
85
183
  }
86
184
  const definition = inspected.definition;
185
+ const environmentDefinition = getFunctionEnvironment(candidate);
186
+ if (environmentDefinition === undefined) {
187
+ return yield* FunctionUnavailable.make({
188
+ cause: new Error(`${functionAddress} has no environment binding.`),
189
+ function: functionAddress,
190
+ message: `Could not load ${functionAddress}.`,
191
+ });
192
+ }
87
193
 
88
194
  if (definition._tag !== kind) {
89
195
  return yield* WrongFunctionKind.make({
@@ -105,7 +211,22 @@ export class FunctionRegistry extends Context.Service<
105
211
  ),
106
212
  );
107
213
 
108
- return { args: decodedArgs, definition, schema: inspected.schema };
214
+ const loadedEnvironment = yield* developmentEnvironment.snapshot.pipe(
215
+ Effect.mapError((cause) =>
216
+ FunctionUnavailable.make({
217
+ cause,
218
+ function: functionAddress,
219
+ message: `Could not load ${functionAddress}.`,
220
+ }),
221
+ ),
222
+ );
223
+
224
+ return {
225
+ args: decodedArgs,
226
+ definition,
227
+ environment: loadedEnvironment.value,
228
+ schema: inspected.schema,
229
+ };
109
230
  }),
110
231
  });
111
232
  }),
@@ -125,6 +246,7 @@ export class FunctionExecutor extends Context.Service<
125
246
  functionAddress: FunctionAddress,
126
247
  kind: FunctionKind,
127
248
  resolved: ResolvedFunction,
249
+ identity?: SessionState,
128
250
  ) => Effect.Effect<RuntimeInvocationResult>;
129
251
  }
130
252
  >()("ignotum/dev-runtime/functions/FunctionExecutor") {
@@ -164,91 +286,113 @@ export class FunctionExecutor extends Context.Service<
164
286
  );
165
287
 
166
288
  return FunctionExecutor.of({
167
- execute: Effect.fn("FunctionExecutor.execute")(function* (functionAddress, kind, resolved) {
168
- const invoke = (context: RuntimeHandlerContext) =>
169
- Effect.gen(() => resolved.definition.handler(context, resolved.args)).pipe(
170
- Effect.matchEffect({
171
- onFailure: (error) => encodeFailure(resolved.definition, error),
172
- onSuccess: (value) => encodeSuccess(resolved.definition, value),
289
+ execute: Effect.fn("FunctionExecutor.execute")(
290
+ function* (functionAddress, kind, resolved, identity) {
291
+ let readId = false;
292
+ const id = makeIdContext(
293
+ Effect.sync(() => {
294
+ readId = true;
295
+ return identity?.user ?? null;
173
296
  }),
174
297
  );
298
+ const invoke = (context: LocalMutationContext | LocalQueryContext) =>
299
+ Effect.gen(() =>
300
+ resolved.definition.handler(
301
+ Object.freeze({ ...context, id, env: resolved.environment ?? Object.freeze({}) }),
302
+ resolved.args,
303
+ ),
304
+ ).pipe(
305
+ Effect.matchEffect({
306
+ onFailure: (error) => encodeFailure(resolved.definition, error),
307
+ onSuccess: (value) => encodeSuccess(resolved.definition, value),
308
+ }),
309
+ );
175
310
 
176
- const execution =
177
- kind === "Query"
178
- ? database.trackedQueryTransaction(resolved.schema, invoke).pipe(
179
- Effect.map(
180
- ({ dependencies, observedRevision, value }): RuntimeInvocationResult => {
181
- const files =
182
- value.type === "Success" && value.value !== undefined
183
- ? encodedFileOccurrencesOf(value.value)
184
- : [];
185
- const result = {
186
- type: "Query",
187
- result: value,
188
- dependencies,
189
- observedRevision,
190
- } as const;
191
- return files.length === 0 ? result : { ...result, files };
192
- },
193
- ),
194
- )
195
- : mutationSemaphore.withPermits(1)(
196
- database
197
- .trackedMutationTransaction(resolved.schema, (context) =>
198
- invoke(context).pipe(
199
- Effect.flatMap((result) =>
200
- result.type === "Failure"
201
- ? Effect.fail(MutationApplicationFailure.make({ error: result.error }))
202
- : Effect.succeed(result),
203
- ),
204
- ),
205
- )
206
- .pipe(
207
- Effect.map(
208
- ({ committedRevision, invalidations, value }): RuntimeInvocationResult => ({
209
- type: "Mutation",
311
+ const execution =
312
+ kind === "Query"
313
+ ? database.trackedQueryTransaction(resolved.schema, invoke).pipe(
314
+ Effect.map(
315
+ ({ dependencies, observedRevision, value }): RuntimeInvocationResult => {
316
+ const files =
317
+ value.type === "Success" && value.value !== undefined
318
+ ? encodedFileOccurrencesOf(value.value)
319
+ : [];
320
+ const result = {
321
+ type: "Query",
210
322
  result: value,
211
- invalidations,
212
- committedRevision,
213
- }),
214
- ),
215
- Effect.catchTags({
216
- MutationApplicationFailure: ({ error }) =>
217
- Effect.succeed<RuntimeInvocationResult>({
323
+ dependencies: readId
324
+ ? [...dependencies, { type: "Id" as const }]
325
+ : dependencies,
326
+ observedRevision,
327
+ } as const;
328
+ return files.length === 0 ? result : { ...result, files };
329
+ },
330
+ ),
331
+ )
332
+ : mutationSemaphore.withPermits(1)(
333
+ database
334
+ .trackedMutationTransaction(resolved.schema, (context) =>
335
+ invoke(context).pipe(
336
+ Effect.flatMap((result) =>
337
+ result.type === "Failure"
338
+ ? Effect.fail(
339
+ MutationApplicationFailure.make({ error: result.error }),
340
+ )
341
+ : Effect.succeed(result),
342
+ ),
343
+ ),
344
+ )
345
+ .pipe(
346
+ Effect.map(
347
+ ({
348
+ committedRevision,
349
+ invalidations,
350
+ value,
351
+ }): RuntimeInvocationResult => ({
218
352
  type: "Mutation",
219
- result: { type: "Failure", error },
353
+ result: value,
354
+ invalidations,
355
+ committedRevision,
220
356
  }),
221
- }),
222
- ),
223
- );
224
-
225
- return yield* execution.pipe(
226
- Effect.orDie,
227
- Effect.catchCauseIf(
228
- (cause) => !Cause.hasInterruptsOnly(cause),
229
- (cause) =>
230
- Effect.gen(function* () {
231
- const requestId = yield* ids.generate(RequestId);
232
- yield* Effect.logError(`${functionAddress} failed during execution.`).pipe(
233
- Effect.annotateLogs({ cause, function: functionAddress, requestId }),
357
+ ),
358
+ Effect.catchTags({
359
+ MutationApplicationFailure: ({ error }) =>
360
+ Effect.succeed<RuntimeInvocationResult>({
361
+ type: "Mutation",
362
+ result: { type: "Failure", error },
363
+ }),
364
+ }),
365
+ ),
234
366
  );
235
- const result = {
236
- type: "Failure" as const,
237
- error: { _tag: "InternalServerError", requestId },
238
- };
239
- if (kind === "Mutation") {
240
- return { type: "Mutation" as const, result };
241
- }
242
- return {
243
- type: "Query" as const,
244
- result,
245
- dependencies: [],
246
- observedRevision: yield* database.revision,
247
- };
248
- }),
249
- ),
250
- );
251
- }),
367
+
368
+ return yield* execution.pipe(
369
+ Effect.orDie,
370
+ Effect.catchCauseIf(
371
+ (cause) => !Cause.hasInterruptsOnly(cause),
372
+ (cause) =>
373
+ Effect.gen(function* () {
374
+ const requestId = yield* ids.generate(RequestId);
375
+ yield* Effect.logError(`${functionAddress} failed during execution.`).pipe(
376
+ Effect.annotateLogs({ cause, function: functionAddress, requestId }),
377
+ );
378
+ const result = {
379
+ type: "Failure" as const,
380
+ error: { _tag: "InternalServerError", requestId },
381
+ };
382
+ if (kind === "Mutation") {
383
+ return { type: "Mutation" as const, result };
384
+ }
385
+ return {
386
+ type: "Query" as const,
387
+ result,
388
+ dependencies: readId ? [{ type: "Id" as const }] : [],
389
+ observedRevision: yield* database.revision,
390
+ };
391
+ }),
392
+ ),
393
+ );
394
+ },
395
+ ),
252
396
  });
253
397
  }),
254
398
  );
@@ -261,10 +405,12 @@ export const functionRuntimeLayer = Layer.effect(
261
405
  const registry = yield* FunctionRegistry;
262
406
  const executor = yield* FunctionExecutor;
263
407
  return FunctionRuntime.of({
264
- prepare: Effect.fn("DevFunctionRuntime.prepare")(function* (functionAddress, kind, args) {
265
- const resolved = yield* registry.resolve(functionAddress, kind, args);
266
- return { execute: executor.execute(functionAddress, kind, resolved) };
267
- }),
408
+ prepare: Effect.fn("DevFunctionRuntime.prepare")(
409
+ function* (functionAddress, kind, args, identity) {
410
+ const resolved = yield* registry.resolve(functionAddress, kind, args);
411
+ return { execute: executor.execute(functionAddress, kind, resolved, identity) };
412
+ },
413
+ ),
268
414
  });
269
415
  }),
270
416
  );
@@ -1,3 +1,175 @@
1
- import { IdGenerator } from "@ignotum/shared/id";
1
+ import { Context, Effect, FileSystem, Layer, Path, Schema, Semaphore } from "effect";
2
+ import {
3
+ DevelopmentUsername,
4
+ developmentSelectionKey,
5
+ ProfileFields,
6
+ SessionRequest,
7
+ User,
8
+ UserId,
9
+ type SessionState,
10
+ } from "@ignotum/contracts/id";
11
+ import * as Versioned from "@ignotum/contracts/versioned";
2
12
 
3
- export const idGeneratorLayer = IdGenerator.layer;
13
+ const Profile = Schema.Struct({ user: User, share: ProfileFields, revision: Schema.Natural });
14
+ const ProfilesV1 = Schema.Struct({
15
+ formatVersion: Schema.Literal(1),
16
+ profiles: Schema.Record(Schema.String, Profile),
17
+ });
18
+ const Profiles = Versioned.initial(ProfilesV1);
19
+ const ProfilesJson = Schema.fromJsonString(Profiles);
20
+
21
+ export const localIdPath = "/_ignotum/v1/id";
22
+ export const LocalCompletion = Schema.Struct({
23
+ request: SessionRequest,
24
+ username: DevelopmentUsername,
25
+ name: Schema.String,
26
+ email: Schema.String,
27
+ image: Schema.String,
28
+ share: ProfileFields,
29
+ manage: Schema.optional(Schema.Boolean),
30
+ });
31
+
32
+ const projection = (user: User, share: ReadonlyArray<string>): User => ({
33
+ id: user.id,
34
+ name: share.includes("name") ? user.name : undefined,
35
+ email: share.includes("email") ? user.email : undefined,
36
+ image: share.includes("image") ? user.image : undefined,
37
+ });
38
+
39
+ export class LocalId extends Context.Service<
40
+ LocalId,
41
+ {
42
+ readonly profile: (username: string) => Effect.Effect<typeof Profile.Type | undefined>;
43
+ readonly session: (username: string | null, epoch: string) => Effect.Effect<SessionState>;
44
+ readonly complete: (input: typeof LocalCompletion.Type) => Effect.Effect<void>;
45
+ }
46
+ >()("ignotum/dev-runtime/id/LocalId") {
47
+ static readonly layer = (directory: string) =>
48
+ Layer.effect(
49
+ LocalId,
50
+ Effect.gen(function* () {
51
+ const fs = yield* FileSystem.FileSystem;
52
+ const path = yield* Path.Path;
53
+ const file = path.join(directory, ".ignotum", "id.json");
54
+ const lock = yield* Semaphore.make(1);
55
+ const load = Effect.fn("LocalId.load")(function* () {
56
+ if (!(yield* fs.exists(file))) return Profiles.make({ formatVersion: 1, profiles: {} });
57
+ return yield* fs
58
+ .readFileString(file)
59
+ .pipe(Effect.flatMap(Schema.decodeEffect(ProfilesJson)));
60
+ });
61
+ return LocalId.of({
62
+ profile: Effect.fn("LocalId.profile")(function* (username) {
63
+ return (yield* load().pipe(Effect.orDie)).profiles[username];
64
+ }),
65
+ session: Effect.fn("LocalId.session")(function* (username, epoch) {
66
+ if (username === null)
67
+ return {
68
+ sessionEpoch: epoch,
69
+ viewRevision: 0,
70
+ user: null,
71
+ validUntil: Number.MAX_SAFE_INTEGER,
72
+ };
73
+ const id = yield* Schema.decodeEffect(UserId)(username).pipe(Effect.orDie);
74
+ const profile = (yield* load().pipe(Effect.orDie)).profiles[id];
75
+ const user: User = projection(profile?.user ?? { id }, profile?.share ?? []);
76
+ return {
77
+ sessionEpoch: epoch,
78
+ viewRevision: profile?.revision ?? 0,
79
+ user,
80
+ validUntil: Number.MAX_SAFE_INTEGER,
81
+ };
82
+ }),
83
+ complete: Effect.fn("LocalId.complete")(function* (input) {
84
+ yield* lock
85
+ .withPermits(1)(
86
+ Effect.gen(function* () {
87
+ const stored = yield* load();
88
+ const previous = stored.profiles[input.username];
89
+ const user = User.make({
90
+ id: UserId.make(input.username),
91
+ name: input.name === "" ? undefined : input.name,
92
+ email: input.email === "" ? undefined : input.email,
93
+ image: input.image === "" ? undefined : input.image,
94
+ });
95
+ const share =
96
+ input.manage === true
97
+ ? input.share
98
+ : [
99
+ ...new Set([
100
+ ...(previous?.share ?? []),
101
+ ...input.share.filter((field) => input.request.profile.includes(field)),
102
+ ]),
103
+ ];
104
+ const updated = Profiles.make({
105
+ formatVersion: 1,
106
+ profiles: {
107
+ ...stored.profiles,
108
+ [input.username]: {
109
+ user,
110
+ share,
111
+ revision:
112
+ (previous?.revision ?? 0) +
113
+ Number(
114
+ JSON.stringify(
115
+ projection(previous?.user ?? { id: user.id }, previous?.share ?? []),
116
+ ) !== JSON.stringify(projection(user, share)),
117
+ ),
118
+ },
119
+ },
120
+ });
121
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true });
122
+ yield* Effect.scoped(
123
+ Effect.gen(function* () {
124
+ const temporary = yield* fs.makeTempFileScoped({
125
+ directory: path.dirname(file),
126
+ prefix: ".id-",
127
+ });
128
+ yield* fs.writeFileString(
129
+ temporary,
130
+ yield* Schema.encodeEffect(ProfilesJson)(updated),
131
+ );
132
+ yield* fs.rename(temporary, file);
133
+ }),
134
+ );
135
+ }),
136
+ )
137
+ .pipe(Effect.orDie);
138
+ }),
139
+ });
140
+ }),
141
+ );
142
+ }
143
+
144
+ const escapeHtml = (text: string) =>
145
+ text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
146
+
147
+ export const localIdPage = (request: typeof SessionRequest.Type): string => {
148
+ const encoded = JSON.stringify(request).replaceAll("<", "\\u003c");
149
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Ignotum ID · Development</title>
150
+ <meta name="viewport" content="width=device-width,initial-scale=1"><style>
151
+ body{font:16px system-ui;background:#f7f7f5;color:#20211f;max-width:420px;margin:8vh auto;padding:24px}
152
+ label{display:block;margin:16px 0}input:not([type=checkbox]){box-sizing:border-box;width:100%;padding:10px;border:1px solid #bbb;border-radius:6px}
153
+ button{padding:12px 18px;border:0;border-radius:6px;background:#222;color:white;cursor:pointer;margin-right:8px}
154
+ p{line-height:1.5}small{color:#666}</style></head><body><h1>Ignotum ID</h1><p>Choose a local username to try your app.</p>
155
+ <form id="form"><label>Username<input name="username" required pattern="[a-z][a-z0-9_-]{0,63}" placeholder="john"></label>
156
+ <label>Name<input name="name"></label><label>Email<input name="email" type="email"></label><label>Image URL<input name="image" type="url"></label>
157
+ ${["name", "email", "image"].map((field) => `<label><input type="checkbox" name="share" value="${escapeHtml(field)}"> Share ${escapeHtml(field)}</label>`).join("")}
158
+ <label><input type="checkbox" name="manage"> Replace existing sharing choices</label>
159
+ <p><small>Profile sharing is optional. No password or account is needed in development.</small></p>
160
+ <button>Continue</button><button type="button" id="cancel">Cancel</button><p id="error"></p></form>
161
+ <script type="module">
162
+ const request=${encoded};const form=document.querySelector('#form');
163
+ const previous=JSON.parse(sessionStorage.getItem('${developmentSelectionKey}')||'null');
164
+ if(previous?.username)form.elements.username.value=previous.username;
165
+ if(request.intent==='requestProfile')form.elements.username.readOnly=true;
166
+ let loading=false;const loadProfile=async()=>{loading=true;try{const response=await fetch('${localIdPath}/profile?username='+encodeURIComponent(form.elements.username.value),{headers:{'x-ignotum-request':'1'}});if(!response.ok)throw new Error('Could not load the local profile.');const profile=await response.json();for(const field of ['name','email','image']){form.elements[field].value=profile?.user[field]??'';form.querySelector('input[name=share][value='+field+']').checked=profile?.share.includes(field)??false;}}finally{loading=false;}};form.elements.username.onchange=loadProfile;if(previous?.username)await loadProfile();
167
+ document.querySelector('#cancel').onclick=()=>{sessionStorage.setItem('ignotum.id.outcome','Cancelled');location.replace(request.returnTo)};
168
+ form.onsubmit=async(e)=>{e.preventDefault();if(loading)return;const data=new FormData(form);
169
+ try{const response=await fetch('${localIdPath}',{method:'POST',headers:{'content-type':'application/json','x-ignotum-request':'1'},body:JSON.stringify({request,username:data.get('username'),name:data.get('name'),email:data.get('email'),image:data.get('image'),share:data.getAll('share'),manage:data.get('manage')==='on'})});
170
+ if(!response.ok)throw new Error('Could not update your local ID.');
171
+ sessionStorage.setItem('${developmentSelectionKey}',JSON.stringify({formatVersion:1,username:data.get('username'),epoch:request.intent==='requestProfile'?previous.epoch:'dev:'+crypto.randomUUID()}));
172
+ sessionStorage.setItem('ignotum.id.outcome',request.intent==='requestProfile'?'ProfileUpdated':'SignedIn');location.replace(request.returnTo);
173
+ }catch(error){document.querySelector('#error').textContent=error.message}}
174
+ </script></body></html>`;
175
+ };