dci-mcp 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/README.md +61 -0
- package/dist/index.js +127 -0
- package/docs/core.md +156 -0
- package/docs/javascript/examples.md +437 -0
- package/docs/javascript/instructions.md +61 -0
- package/docs/typescript/examples.md +409 -0
- package/docs/typescript/instructions.md +59 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# dci-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that generates code adhering to the [DCI architecture](https://en.wikipedia.org/wiki/Data,_context_and_interaction) when generating or refactoring code. It loads language-specific rules and examples, then instructs the LLM to apply them immediately — no back-and-forth.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | When to use |
|
|
8
|
+
| -------------------------------- | -------------------------------------------------------------------------- |
|
|
9
|
+
| `prepare_dci_refactor` | User wants to refactor existing code into DCI |
|
|
10
|
+
| `scaffold_dci_from_mental_model` | User describes a mental model / user story and wants DCI code from scratch |
|
|
11
|
+
|
|
12
|
+
Both tools accept a `language` argument (e.g. `"typescript"`, `"javascript"`) and return the full DCI ruleset for that language as context.
|
|
13
|
+
|
|
14
|
+
## Supported languages
|
|
15
|
+
|
|
16
|
+
Add a folder under `docs/` with `instructions.md` (required) and `examples.md` (optional):
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
docs/
|
|
20
|
+
core.md # DCI rules shared across all languages
|
|
21
|
+
typescript/
|
|
22
|
+
instructions.md
|
|
23
|
+
examples.md
|
|
24
|
+
javascript/
|
|
25
|
+
instructions.md
|
|
26
|
+
examples.md
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage in mcp.json (stdio)
|
|
30
|
+
|
|
31
|
+
```jsonc
|
|
32
|
+
"dci": {
|
|
33
|
+
"command": "pnpx",
|
|
34
|
+
"args": ["dci-mcp"]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or with npx:
|
|
39
|
+
|
|
40
|
+
```jsonc
|
|
41
|
+
"dci": {
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["-y", "dci-mcp"]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Development
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pnpm install
|
|
51
|
+
pnpm dev # build + pnpm link (makes dci-mcp available globally)
|
|
52
|
+
pnpm inspector # open MCP Inspector connected to the local server
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
After `pnpm dev`, the server is available as `dci-mcp` in your PATH and can be used in `mcp.json` outside this project while you iterate:
|
|
56
|
+
|
|
57
|
+
```jsonc
|
|
58
|
+
"dci": {
|
|
59
|
+
"command": "dci-mcp"
|
|
60
|
+
}
|
|
61
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
10
|
+
// Register the Tools
|
|
11
|
+
const languageSchema = z.object({
|
|
12
|
+
language: z
|
|
13
|
+
.string()
|
|
14
|
+
.describe("The target programming language, matching a folder name under docs/ (e.g. 'typescript', 'javascript')"),
|
|
15
|
+
});
|
|
16
|
+
// Initialize the MCP Server
|
|
17
|
+
const server = new McpServer({
|
|
18
|
+
name: "dci-mcp",
|
|
19
|
+
version,
|
|
20
|
+
});
|
|
21
|
+
// Calculate paths to the docs directory
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const DOCS_DIR = path.join(__dirname, "..", "docs");
|
|
24
|
+
/**
|
|
25
|
+
* Helper function to read, concatenate, and append action-specific directives.
|
|
26
|
+
*/
|
|
27
|
+
async function getDciInstructions(language, action) {
|
|
28
|
+
const corePath = path.join(DOCS_DIR, "core.md");
|
|
29
|
+
const langPath = path.join(DOCS_DIR, language, "instructions.md");
|
|
30
|
+
const examplesPath = path.join(DOCS_DIR, language, "examples.md");
|
|
31
|
+
try {
|
|
32
|
+
// Read core and language instructions concurrently; examples are optional
|
|
33
|
+
const [coreContent, langContent] = await Promise.all([
|
|
34
|
+
fs.readFile(corePath, "utf-8"),
|
|
35
|
+
fs.readFile(langPath, "utf-8"),
|
|
36
|
+
]);
|
|
37
|
+
let examplesContent = "";
|
|
38
|
+
try {
|
|
39
|
+
examplesContent = await fs.readFile(examplesPath, "utf-8");
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
if (err.code !== "ENOENT")
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
let response = `${coreContent}\n\n${langContent}`;
|
|
46
|
+
if (examplesContent) {
|
|
47
|
+
response += `\n\n${examplesContent}`;
|
|
48
|
+
}
|
|
49
|
+
response += "\n\n";
|
|
50
|
+
// Append the action-specific LLM directive
|
|
51
|
+
if (action === "refactor") {
|
|
52
|
+
response += `
|
|
53
|
+
---
|
|
54
|
+
**CRITICAL INSTRUCTION FOR YOUR NEXT RESPONSE:**
|
|
55
|
+
You now have the strict DCI rules. Do not ask for confirmation. Immediately analyze the user's legacy code, silently plan the Data/Roles/Context, and generate the refactored DCI code in your next response.
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
else if (action === "scaffold") {
|
|
59
|
+
response += `
|
|
60
|
+
---
|
|
61
|
+
**CRITICAL INSTRUCTION FOR YOUR NEXT RESPONSE:**
|
|
62
|
+
You now have the strict DCI rules. Do not ask for confirmation. Read the user's mental model/user story from the chat history and immediately translate it into a DCI Context.
|
|
63
|
+
|
|
64
|
+
As you generate the code, ensure:
|
|
65
|
+
1. **Roles map to the actors/concepts** in their mental model.
|
|
66
|
+
2. **RoleMethods express the exact steps** described in their story.
|
|
67
|
+
3. You strictly separate the dumb Data ("what the system is") from the Context ("what the system does").
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
return response;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
throw new Error(`Failed to load DCI instructions for '${language}'. Ensure the language folder exists in the docs/ directory. Error details: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
server.registerTool("prepare_dci_refactor", {
|
|
77
|
+
title: "Prepare DCI Refactor",
|
|
78
|
+
description: "Call this tool when the user asks to refactor code into the DCI paradigm. Pass the target language. The tool will return the strict DCI architectural rules you need to follow before generating the final code.",
|
|
79
|
+
inputSchema: languageSchema,
|
|
80
|
+
}, async ({ language }) => {
|
|
81
|
+
try {
|
|
82
|
+
const content = await getDciInstructions(language.toLowerCase(), "refactor");
|
|
83
|
+
return { content: [{ type: "text", text: content }] };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
89
|
+
type: "text",
|
|
90
|
+
text: `Error executing tool: ${error.message}. Please inform the user that this language might not be supported in the DCI server yet.`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
isError: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
server.registerTool("scaffold_dci_from_mental_model", {
|
|
98
|
+
title: "Scaffold DCI from Mental Model",
|
|
99
|
+
description: "Call this tool when the user provides a mental model or user story and wants you to write a new DCI Context from scratch. The tool will return the strict DCI architectural rules required to translate their mental model into code.",
|
|
100
|
+
inputSchema: languageSchema,
|
|
101
|
+
}, async ({ language }) => {
|
|
102
|
+
try {
|
|
103
|
+
const content = await getDciInstructions(language.toLowerCase(), "scaffold");
|
|
104
|
+
return { content: [{ type: "text", text: content }] };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: "text",
|
|
111
|
+
text: `Error executing tool: ${error.message}. Please inform the user that this language might not be supported in the DCI server yet.`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
isError: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
// Start the server using stdio transport
|
|
119
|
+
async function main() {
|
|
120
|
+
const transport = new StdioServerTransport();
|
|
121
|
+
await server.connect(transport);
|
|
122
|
+
console.error("DCI Architect MCP Server running on stdio");
|
|
123
|
+
}
|
|
124
|
+
main().catch((error) => {
|
|
125
|
+
console.error("Server error:", error);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|
package/docs/core.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Instructions for Writing Code with the DCI Paradigm
|
|
2
|
+
|
|
3
|
+
> **DCI: Data, Context and Interaction:**
|
|
4
|
+
> DCI is a programming paradigm that separates _what the system is_ (domain knowledge/data models) from _what the system does_ (behavior/functionality), bridging human mental models and code.
|
|
5
|
+
|
|
6
|
+
## 1. Core DCI Architecture
|
|
7
|
+
|
|
8
|
+
- DCI code is organized around three projections:
|
|
9
|
+
|
|
10
|
+
### Data ("What the system _is_")
|
|
11
|
+
|
|
12
|
+
- Domain objects, with simple properties and methods that only regards its own data.
|
|
13
|
+
- Pure data structures that represent the state of the system.
|
|
14
|
+
- Classes or types that do NOT contain interaction logic relevant to the current use case.
|
|
15
|
+
|
|
16
|
+
### Context ("What the system _does_")
|
|
17
|
+
|
|
18
|
+
- Encapsulates a _use case_ based on a mental model.
|
|
19
|
+
- Orchestrates interactions between Data objects by assigning them **Roles** at runtime.
|
|
20
|
+
- The (public) properties of these Data objects form the **Role Contracts**, a partial interface for accessing the role-playing object by its Role.
|
|
21
|
+
- A Context encapsulates one complete use case or user story, with all variations expressed in the Context.
|
|
22
|
+
|
|
23
|
+
### Interaction ("How the system does it")
|
|
24
|
+
|
|
25
|
+
- Specifies _how_ objects collaborate inside a Context - via **RoleMethods**.
|
|
26
|
+
- RoleMethods define the behavior of objects playing specific Roles.
|
|
27
|
+
- IMPORTANT: ONLY the Role's own RoleMethods can access its Role Contract (the underlying object properties) directly. There CAN NOT be any access to the Role Contract from outside the RoleMethods of that Role, not even from other RoleMethods of other Roles. The only way to access the Role Contract from other Roles is through RoleMethod calls.
|
|
28
|
+
- Internal (private) RoleMethods are callable _only_ by RoleMethods in the same Role.
|
|
29
|
+
- Interactions should favor "ask, don't tell" (objects request services, not micromanage).
|
|
30
|
+
- The starting point for a Role interaction (a flow of messages through the Context) is called a **System Operation**.
|
|
31
|
+
- In a true DCI runtime, RoleMethods are attached _dynamically_ to the objects playing the Roles, and only exist during Context execution, but this is language- and implementation-specific.
|
|
32
|
+
|
|
33
|
+
## 2. DCI Principles & Key Concepts
|
|
34
|
+
|
|
35
|
+
### Mental Model Alignment
|
|
36
|
+
|
|
37
|
+
- DCI code should map closely to how users think about the domain.
|
|
38
|
+
- The RoleMethods should express what the user wants the role-playing objects to do, based on their properties.
|
|
39
|
+
|
|
40
|
+
### Roles
|
|
41
|
+
|
|
42
|
+
- Role = An identifier for an object in a Context; not a reusable type.
|
|
43
|
+
- Objects can play a Role if they fulfill the Role's contract (literal type).
|
|
44
|
+
- Roles are _not wrappers_; object identity MUST be preserved.
|
|
45
|
+
|
|
46
|
+
### Object Identity
|
|
47
|
+
|
|
48
|
+
- Real objects, not proxies or wrappers, play Roles to maintain their identity.
|
|
49
|
+
|
|
50
|
+
### Separation of Concerns
|
|
51
|
+
|
|
52
|
+
- Domain knowledge (Data) evolves slowly; use case logic (Context/Interaction) changes rapidly.
|
|
53
|
+
- Keep these separate for maintainability.
|
|
54
|
+
|
|
55
|
+
### Readability
|
|
56
|
+
|
|
57
|
+
- Gather use case logic in one place (the Context).
|
|
58
|
+
- Use comments and types to clarify contracts and intent.
|
|
59
|
+
|
|
60
|
+
### Runtime Focus
|
|
61
|
+
|
|
62
|
+
- DCI describes system behavior _at runtime_, not just compile-time structure.
|
|
63
|
+
|
|
64
|
+
### Agile Support
|
|
65
|
+
|
|
66
|
+
- DCI supports practices like iterative development, clear mental models, and adaptation to change.
|
|
67
|
+
|
|
68
|
+
### When not to use DCI
|
|
69
|
+
|
|
70
|
+
- DCI is best suited for use cases where there are two or more interacting actors.
|
|
71
|
+
- For simple operations like CRUD, or purely functional data transformation, do not use DCI.
|
|
72
|
+
- If a use case tends to contain only one Role or be specific enough not to express a genericity of its Role interfaces, do not use DCI.
|
|
73
|
+
|
|
74
|
+
## 3. DCI Analogies
|
|
75
|
+
|
|
76
|
+
### Movie Script
|
|
77
|
+
|
|
78
|
+
- The Context is the script; objects are actors; Roles are character parts.
|
|
79
|
+
- Objects (actors) can play different Roles in different Contexts (scenes).
|
|
80
|
+
|
|
81
|
+
### Train System
|
|
82
|
+
|
|
83
|
+
- Instead of modeling trains or stations individually, DCI models their patterns of interaction (e.g., station visits).
|
|
84
|
+
|
|
85
|
+
### Automated factory
|
|
86
|
+
|
|
87
|
+
- When producing different products, the factory (Context) assigns machines (objects) to different Roles based on the product being made.
|
|
88
|
+
- The product is passed between the machines (through Role Method arguments), each using its Role Contract to modify or use it, until the goal of the Context has been achieved.
|
|
89
|
+
|
|
90
|
+
### Extending the Context
|
|
91
|
+
|
|
92
|
+
- Extending the Context (adding variations to the use case) should be like rewiring cables (RoleMethod calls), not changing domain objects. The simple data playing the Context Roles should be able to play a part in many different scenarios through their interfaces.
|
|
93
|
+
|
|
94
|
+
## 4. DCI Code Generation Workflow
|
|
95
|
+
|
|
96
|
+
1. **Start with the Use Case**
|
|
97
|
+
|
|
98
|
+
- What does the user want to _achieve_?
|
|
99
|
+
- Define this as a Context.
|
|
100
|
+
- If a mental model or use case is supplied, use it as a foundation for the Roles and RoleMethods.
|
|
101
|
+
- Do NOT name the context "SubmitContext" or similar, but rather after the use case (e.g., `SubmitForm`, `LibraryMachine`).
|
|
102
|
+
|
|
103
|
+
2. **Identify Roles**
|
|
104
|
+
|
|
105
|
+
- What objects collaborate for this use case?
|
|
106
|
+
- Roles must be located _inside_ the Context.
|
|
107
|
+
- Roles should be played by _objects_, not primitive types. Primitive types passed to the Context, like configuration options, can be expressed as a settings object, played by a `Context` role.
|
|
108
|
+
- Additional Context state, usually transient, can also be added as properties to the `Context` role if few and simple, otherwise a separate Role can be created for it, usually when a Context needs to construct an object throughout its Interaction, like a `Response` to a HTTP request.
|
|
109
|
+
- Name the Roles meaningfully (e.g., `SourceAccount`, `Messages`), do NOT append `Role` to the name.
|
|
110
|
+
- DON'T add Roles that are not relevant to the use case/mental model, or just for technical reasons (e.g., a `Database` Role for database access, `ResponseComposer` for constructing a HTTP response, or roles that act like software design patterns). Instead, consider whether the technical dependency can be abstracted behind a Role Contract of an existing Role, or if it is truly needed as a separate Role.
|
|
111
|
+
|
|
112
|
+
3. **Define Role Contracts**
|
|
113
|
+
|
|
114
|
+
- What properties/methods must an object have for its Role, for the Context goal to be fulfilled?
|
|
115
|
+
- Define clear, minimal contracts that specify the interface needed for each Role.
|
|
116
|
+
- Use the language's type system to express these contracts explicitly.
|
|
117
|
+
|
|
118
|
+
4. **Implement RoleMethods**
|
|
119
|
+
|
|
120
|
+
- Write interaction logic _inside_ the Context.
|
|
121
|
+
- Group RoleMethods by Role for clarity.
|
|
122
|
+
- RoleMethods should be kept together. No mixing of RoleMethods or other instructions between RoleMethods belonging to the same role.
|
|
123
|
+
- DON'T add RoleMethods without Roles, that's just helper functions in disguise. RoleMethods MUST have a corresponding Role identifier with a Contract, so they further the Context goal ("doing their part" in the use case) and are not just utility functions, which _can_ exist on a Role but usually as private RoleMethods.
|
|
124
|
+
- Most of the time, RoleMethods should "chain" together the Interaction in progress, meaning that at the end of a RoleMethod, another RoleMethod of a Role is called, passing on relevant data as arguments, further fulfilling the purpose of the Context according to the mental model. This chaining avoids the dependency on return values and makes it easier to "rewire" the context later if requirements change, or new functionality is added.
|
|
125
|
+
- If a RoleMethod is called only once in the Context, it is usually better to inline its logic into the caller RoleMethod to avoid unnecessary indirection. But if it is called multiple times, or if it is a distinct step in the use case that can be clearly named, it can be a separate RoleMethod (if it can be connected to a relevant Role).
|
|
126
|
+
|
|
127
|
+
5. **Focus on Interaction**
|
|
128
|
+
|
|
129
|
+
- RoleMethods should coordinate with other Roles ("ask"), not dictate ("tell").
|
|
130
|
+
- When data is acquired or created within a RoleMethod, for example through a Role Contract method call, if needed by other Roles it should be passed to other RoleMethods, expressing the interaction and collaboration of the Roles - true object-orientation.
|
|
131
|
+
- Return values should be avoided if possible (think message-passing that ultimately modifies state), but is not prohibited, for example an occasional boolean check. Readability is the goal, not enforcing rules that complicates the code.
|
|
132
|
+
|
|
133
|
+
6. **Keep Data Pure**
|
|
134
|
+
|
|
135
|
+
- Domain objects (classes/types) must NOT contain Context-specific logic.
|
|
136
|
+
|
|
137
|
+
7. **Preserve Object Identity**
|
|
138
|
+
|
|
139
|
+
- Role wrappers can lead to subtle bugs with strict equality checks, so NEVER wrap objects for Role assignment - always use direct references.
|
|
140
|
+
|
|
141
|
+
8. **Role-binding**
|
|
142
|
+
|
|
143
|
+
- All Roles _must_ be bound (assigned) either during the Context initialization, or in a single `rebind` function that reassigns _all_ Roles.
|
|
144
|
+
- If one or more Roles must change during the Context execution, prefer reinstantiating the Context again, or use the `rebind` function to avoid recursion for example.
|
|
145
|
+
- Roles _can_ be bound to null, but is unusual and a good reason must exist for that.
|
|
146
|
+
|
|
147
|
+
9. **Nested Contexts**
|
|
148
|
+
|
|
149
|
+
- If a RoleMethod's logic represents a _reusable_, _distinct_ use case, consider implementing it as a separate Context. This keeps Contexts focused and manageable.
|
|
150
|
+
- Calling such a Context within another is called "nesting" Contexts.
|
|
151
|
+
- Follow the rules about when not to use DCI to determine whether to use nested Contexts.
|
|
152
|
+
|
|
153
|
+
10. **Documentation**
|
|
154
|
+
|
|
155
|
+
- Document Contexts clearly with their purpose and use case.
|
|
156
|
+
- Clarify Role Contracts with appropriate type annotations or comments.
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
## JavaScript DCI Examples
|
|
2
|
+
|
|
3
|
+
### AJAX form submit with dynamic error display
|
|
4
|
+
|
|
5
|
+
- Notable as a "one off" operation, nothing is returned from the Context function.
|
|
6
|
+
- Also demonstrates the basics of Context error handling by using a single try/catch around the System Operation part, to avoid errors leaking outside the RoleMethods.
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
/**
|
|
10
|
+
* Submit a form and show error messages from the response.
|
|
11
|
+
* @DCI-context
|
|
12
|
+
* @param {SubmitEvent} e
|
|
13
|
+
*/
|
|
14
|
+
async function SubmitForm(e) {
|
|
15
|
+
if (!(e.target instanceof HTMLFormElement)) throw new Error("No form found.");
|
|
16
|
+
|
|
17
|
+
//#region Form Role ////////////////////
|
|
18
|
+
|
|
19
|
+
/** @type {{ action: string }} */
|
|
20
|
+
const Form = e.target;
|
|
21
|
+
|
|
22
|
+
async function Form_submit() {
|
|
23
|
+
// Role contract: Form.action
|
|
24
|
+
const response = await fetch(Form.action, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
body: new FormData(Form),
|
|
27
|
+
});
|
|
28
|
+
const data = await response.json();
|
|
29
|
+
for (const error of data.errors ?? []) Messages_show(error); // Role interaction
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
|
|
34
|
+
//#region Messages Role ////////////////////
|
|
35
|
+
|
|
36
|
+
const Messages = e.target.querySelectorAll("[data-form-message]");
|
|
37
|
+
|
|
38
|
+
async function Messages_hide() {
|
|
39
|
+
Messages__set("none");
|
|
40
|
+
await Form_submit(); // Role interaction
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string} name */
|
|
44
|
+
function Messages_show(name) {
|
|
45
|
+
Messages__set("unset", name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} display
|
|
50
|
+
* @param {string} [name]
|
|
51
|
+
*/
|
|
52
|
+
function Messages__set(display, name = "") {
|
|
53
|
+
for (const msg of Messages) {
|
|
54
|
+
if (name && msg.dataset.formMessage != name) continue;
|
|
55
|
+
msg.style.display = display;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
console.log("Submit");
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
await Messages_hide(); // System operation
|
|
65
|
+
console.log("Done");
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error(e);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Session validation for SvelteKit and Drizzle
|
|
73
|
+
|
|
74
|
+
- Another "one off" operation, where ultimately the Request is modified to have valid or invalid session data.
|
|
75
|
+
- The Roles are defined in the _Context arguments_, so they will not have their common place before their RoleMethods, which they would if they were defined inside the Context.
|
|
76
|
+
- The System Operation (initial RoleMethod call) is started right away, as all Roles are defined in the Context arguments.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
/**
|
|
80
|
+
* Sets locals.user and locals.session on success, otherwise null.
|
|
81
|
+
* @DCI-context
|
|
82
|
+
* @param {import('@sveltejs/kit').RequestEvent} Request
|
|
83
|
+
* @param {typeof db} [Session]
|
|
84
|
+
* @param {boolean} [INVALIDATE]
|
|
85
|
+
* @returns {Promise<void>}
|
|
86
|
+
*/
|
|
87
|
+
export async function ValidateSession(
|
|
88
|
+
Request,
|
|
89
|
+
Session = db,
|
|
90
|
+
INVALIDATE = false,
|
|
91
|
+
) {
|
|
92
|
+
await Request_getTokenFromCookie();
|
|
93
|
+
|
|
94
|
+
//#region Request //////////////////////////////
|
|
95
|
+
|
|
96
|
+
async function Request_getTokenFromCookie() {
|
|
97
|
+
const token = Request.cookies.get(COOKIE_NAME);
|
|
98
|
+
|
|
99
|
+
if (!token) Request_clearSession();
|
|
100
|
+
else await Session_findByToken(token);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} token
|
|
105
|
+
* @param {import('$lib/db').Session} session
|
|
106
|
+
* @param {import('$lib/db').User} user
|
|
107
|
+
*/
|
|
108
|
+
function Request_setSession(token, session, user) {
|
|
109
|
+
Object.freeze(user);
|
|
110
|
+
Object.freeze(session);
|
|
111
|
+
|
|
112
|
+
Request.cookies.set(COOKIE_NAME, token, {
|
|
113
|
+
httpOnly: true,
|
|
114
|
+
sameSite: "lax",
|
|
115
|
+
expires: session.expiresAt,
|
|
116
|
+
path: "/",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
Request.locals.user = user;
|
|
120
|
+
Request.locals.session = session;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Request_clearSession() {
|
|
124
|
+
Request.cookies.set(COOKIE_NAME, "", {
|
|
125
|
+
httpOnly: true,
|
|
126
|
+
sameSite: "lax",
|
|
127
|
+
maxAge: 0,
|
|
128
|
+
path: "/",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
Request.locals.user = undefined;
|
|
132
|
+
Request.locals.session = undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
//#region Session ////////////////////////////////////////
|
|
136
|
+
|
|
137
|
+
/** @param {string} token */
|
|
138
|
+
async function Session_findByToken(token) {
|
|
139
|
+
const sessionId = encodeHexLowerCase(
|
|
140
|
+
sha256(new TextEncoder().encode(token)),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const [result] = await Session.select({
|
|
144
|
+
user: userTable,
|
|
145
|
+
session: sessionTable,
|
|
146
|
+
})
|
|
147
|
+
.from(sessionTable)
|
|
148
|
+
.innerJoin(userTable, eq(sessionTable.userId, userTable.id))
|
|
149
|
+
.where(eq(sessionTable.id, sessionId))
|
|
150
|
+
.limit(1);
|
|
151
|
+
|
|
152
|
+
if (!result) Request_clearSession();
|
|
153
|
+
else await Session_checkExpiryDate(token, result.session, result.user);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {string} token
|
|
158
|
+
* @param {import('$lib/db').Session} session
|
|
159
|
+
* @param {import('$lib/db').User} user
|
|
160
|
+
*/
|
|
161
|
+
async function Session_checkExpiryDate(token, session, user) {
|
|
162
|
+
if (INVALIDATE || Date.now() >= session.expiresAt.getTime()) {
|
|
163
|
+
await Session.delete(sessionTable).where(eq(sessionTable.id, session.id));
|
|
164
|
+
Request_clearSession();
|
|
165
|
+
} else {
|
|
166
|
+
await Session_refreshExpiryDate(token, session, user);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {string} token
|
|
172
|
+
* @param {import('$lib/db').Session} session
|
|
173
|
+
* @param {import('$lib/db').User} user
|
|
174
|
+
*/
|
|
175
|
+
async function Session_refreshExpiryDate(token, session, user) {
|
|
176
|
+
if (
|
|
177
|
+
Date.now() >=
|
|
178
|
+
session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * (EXPIRY_DAYS / 2)
|
|
179
|
+
) {
|
|
180
|
+
session.expiresAt = new Date(
|
|
181
|
+
Date.now() + 1000 * 60 * 60 * 24 * EXPIRY_DAYS,
|
|
182
|
+
);
|
|
183
|
+
await Session.update(sessionTable)
|
|
184
|
+
.set({
|
|
185
|
+
expiresAt: session.expiresAt,
|
|
186
|
+
})
|
|
187
|
+
.where(eq(sessionTable.id, session.id));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
Request_setSession(token, session, user);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### A book borrowing machine at a public library
|
|
196
|
+
|
|
197
|
+
- Notable as it returns an object from the Context, similar to a class with public methods.
|
|
198
|
+
- The `Screen` and `Printer` Roles are defined in the _Context arguments_, so they will not have their common place before their RoleMethods, which they would if they were defined inside the Context, as the other Roles are.
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
import { Display } from "$lib/assets/screen/screenStates";
|
|
202
|
+
import { title } from "$lib/data/libraryItem";
|
|
203
|
+
import { cards, library, loans } from "$lib/library";
|
|
204
|
+
import { hash } from "$lib/utils";
|
|
205
|
+
import { BorrowItem } from "./borrowItem";
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A book borrowing machine at a public library.
|
|
209
|
+
* @DCI-context
|
|
210
|
+
* @param {{ display: (state: object) => void, currentState: () => object }} Screen
|
|
211
|
+
* @param {{ print: (line: string) => void }} Printer
|
|
212
|
+
*/
|
|
213
|
+
export function LibraryMachine(Screen, Printer) {
|
|
214
|
+
//#region Borrower /////
|
|
215
|
+
|
|
216
|
+
/** @type {{ "@id": string, "@type": "Person", items: { id: string, title: string, expires: Date }[] }} */
|
|
217
|
+
let Borrower;
|
|
218
|
+
|
|
219
|
+
function Borrower_isLoggedIn() {
|
|
220
|
+
// A getter is ok if it is descriptive beyond "get" and returns a boolean
|
|
221
|
+
return !!Borrower["@id"];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {{ "@id": string, "@type": string }} user */
|
|
225
|
+
function Borrower_login(user) {
|
|
226
|
+
rebind(user["@id"]);
|
|
227
|
+
Screen_displayItems(Borrower.items);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {boolean} forced Whether the logout was forced by the user (e.g. card removed)
|
|
232
|
+
* @param {boolean} printItems
|
|
233
|
+
*/
|
|
234
|
+
function Borrower_logout(forced, printItems) {
|
|
235
|
+
// Need to print before rebinding, as it will clear the items
|
|
236
|
+
if (printItems) Printer_printReceipt(Borrower.items);
|
|
237
|
+
|
|
238
|
+
if (Borrower_isLoggedIn()) rebind(undefined);
|
|
239
|
+
Screen_displayThankYou(forced);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @param {string | undefined} itemId */
|
|
243
|
+
function Borrower_borrowItem(itemId) {
|
|
244
|
+
// TODO: Built-in security (assertions) for required login
|
|
245
|
+
if (!Borrower_isLoggedIn() || !itemId) return;
|
|
246
|
+
|
|
247
|
+
if (Borrower.items.find((item) => item.id === itemId)) return;
|
|
248
|
+
|
|
249
|
+
// Call nested context
|
|
250
|
+
const loan = BorrowItem(library, Borrower, { "@id": itemId }, loans);
|
|
251
|
+
|
|
252
|
+
// TODO: Error handling (logging) for expected errors
|
|
253
|
+
if (loan instanceof Error) return Screen_displayError(loan);
|
|
254
|
+
|
|
255
|
+
Borrower.items.push({
|
|
256
|
+
id: loan.object["@id"],
|
|
257
|
+
title: title(loan.object),
|
|
258
|
+
expires: loan.endTime,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
Screen_displayItems(Borrower.items);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
//#endregion
|
|
265
|
+
|
|
266
|
+
//#region CardReader /////
|
|
267
|
+
|
|
268
|
+
const CardReader = {
|
|
269
|
+
currentId: "",
|
|
270
|
+
attempts: 0,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** @param {string | undefined} id */
|
|
274
|
+
function CardReader_cardScanned(id) {
|
|
275
|
+
if (CardReader.currentId == id) return;
|
|
276
|
+
|
|
277
|
+
if (!id) {
|
|
278
|
+
// Card removed or missing
|
|
279
|
+
if (CardReader.currentId) Borrower_logout(true, false);
|
|
280
|
+
} else {
|
|
281
|
+
// Card scanned
|
|
282
|
+
if (!Borrower_isLoggedIn()) {
|
|
283
|
+
// New card
|
|
284
|
+
Screen_displayEnterPIN(0);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
CardReader.currentId = id ?? "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function CardReader_resetAttempts() {
|
|
292
|
+
CardReader.attempts = 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @param {string[]} pin */
|
|
296
|
+
function CardReader_validatePIN(pin) {
|
|
297
|
+
Library_validateCard(CardReader.currentId, pin);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function CardReader_PINfailed() {
|
|
301
|
+
// TODO: Force remove card after 3 failed attempts
|
|
302
|
+
Screen_displayEnterPIN(++CardReader.attempts);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
//#endregion
|
|
306
|
+
|
|
307
|
+
//#region Library /////
|
|
308
|
+
|
|
309
|
+
const Library = {
|
|
310
|
+
cards,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @param {string} cardId
|
|
315
|
+
* @param {string[]} pin
|
|
316
|
+
*/
|
|
317
|
+
function Library_validateCard(cardId, pin) {
|
|
318
|
+
const card = Library.cards.find((card) => card["@id"] === cardId);
|
|
319
|
+
if (card && card.identifier === hash(pin.join(""))) {
|
|
320
|
+
Borrower_login(card._owner);
|
|
321
|
+
} else {
|
|
322
|
+
CardReader_PINfailed();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
//#endregion
|
|
327
|
+
|
|
328
|
+
//#region Screen /////
|
|
329
|
+
|
|
330
|
+
function Screen_displayWelcome() {
|
|
331
|
+
Screen.display({ display: Display.Welcome });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** @param {number} attempts */
|
|
335
|
+
function Screen_displayEnterPIN(attempts) {
|
|
336
|
+
Screen.display({ display: Display.EnterPIN, attempts });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** @param {{ title: string, expires: Date }[]} items */
|
|
340
|
+
function Screen_displayItems(items) {
|
|
341
|
+
Screen.display({ display: Display.Items, items });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** @param {boolean} forced */
|
|
345
|
+
function Screen_displayThankYou(forced) {
|
|
346
|
+
if (forced && Screen.currentState().display === Display.ThankYou) {
|
|
347
|
+
Screen_displayWelcome();
|
|
348
|
+
} else {
|
|
349
|
+
Screen.display({ display: Display.ThankYou });
|
|
350
|
+
if (forced) Screen__displayNext({ display: Display.Welcome });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @param {Error} error */
|
|
355
|
+
function Screen_displayError(error) {
|
|
356
|
+
// Log out user
|
|
357
|
+
rebind(undefined);
|
|
358
|
+
Screen.display({ display: Display.Error, error });
|
|
359
|
+
Screen__displayNext({ display: Display.Welcome }, 10000);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* @param {object} nextState
|
|
364
|
+
* @param {number} [delay]
|
|
365
|
+
*/
|
|
366
|
+
function Screen__displayNext(nextState, delay = 5000) {
|
|
367
|
+
const currentState = Screen.currentState();
|
|
368
|
+
setTimeout(() => {
|
|
369
|
+
if (currentState === Screen.currentState()) Screen.display(nextState);
|
|
370
|
+
}, delay);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
//#endregion
|
|
374
|
+
|
|
375
|
+
//#region Printer /////
|
|
376
|
+
|
|
377
|
+
/** @param {{ title: string, expires: Date }[]} items */
|
|
378
|
+
async function Printer_printReceipt(items) {
|
|
379
|
+
if (items.length) {
|
|
380
|
+
await Printer__printLine(new Date().toISOString().slice(0, 10));
|
|
381
|
+
await Printer__printLine("");
|
|
382
|
+
for (const item of items) {
|
|
383
|
+
await Printer__printLine(item.title);
|
|
384
|
+
await Printer__printLine(
|
|
385
|
+
"Return on " + item.expires.toISOString().slice(0, 10),
|
|
386
|
+
);
|
|
387
|
+
await Printer__printLine("");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** @param {string} line */
|
|
393
|
+
async function Printer__printLine(line) {
|
|
394
|
+
Printer.print(line);
|
|
395
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Reset the Context state, rebind to a new user or undefined (not logged in).
|
|
402
|
+
* @param {string | undefined} userId
|
|
403
|
+
*/
|
|
404
|
+
function rebind(userId) {
|
|
405
|
+
Borrower = { "@id": userId ?? "", "@type": "Person", items: [] };
|
|
406
|
+
CardReader_resetAttempts();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
{
|
|
410
|
+
// Context start
|
|
411
|
+
rebind(undefined);
|
|
412
|
+
Screen_displayWelcome();
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
/** @param {string | undefined} id */
|
|
416
|
+
cardScanned(id) {
|
|
417
|
+
CardReader_cardScanned(id);
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
/** @param {string | undefined} id */
|
|
421
|
+
itemScanned(id) {
|
|
422
|
+
Borrower_borrowItem(id);
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
/** @param {string[]} pin */
|
|
426
|
+
pinEntered(pin) {
|
|
427
|
+
CardReader_validatePIN(pin);
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
/** @param {boolean} printReceipt */
|
|
431
|
+
finish(printReceipt) {
|
|
432
|
+
Borrower_logout(false, printReceipt);
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
## JavaScript-Specific Implementation
|
|
2
|
+
|
|
3
|
+
### Context Implementation
|
|
4
|
+
|
|
5
|
+
- A Context is a function annotated with `@DCI-context`. If that doesn't exist, _do not_ apply DCI.
|
|
6
|
+
- Contexts and their RoleMethods can be async functions when needed.
|
|
7
|
+
|
|
8
|
+
### Role Contracts
|
|
9
|
+
|
|
10
|
+
- Use literal types in JSDoc as Role Contracts, so the code can be understood without deeper type knowledge. Example:
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
/** @type {{ action: string }} */
|
|
14
|
+
const Form = event.target;
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- EXCEPTION: If the types are well-known, like the JavaScript Web APIs, you can reference them directly (e.g., `Page`, `HTMLElement`). Example:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
/** @type {Page} */
|
|
21
|
+
const Page = await Browser.newPage();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- If an object is passed to the Context function that fits the mental model of a Context Role, the Role should be defined from it with the Role Contract as the parameter type. This is the ONLY case RoleMethods should exist in the Context without their Role defined immediately before them. Example:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
/**
|
|
28
|
+
* @DCI-context
|
|
29
|
+
* A speaker proclaims something to the world, that dutifully notes it
|
|
30
|
+
*/
|
|
31
|
+
function HelloWorld(
|
|
32
|
+
/** @type {{ phrase: string }} */ Speaker,
|
|
33
|
+
/** @type {{ log: (msg: unknown) => void }} */ World,
|
|
34
|
+
) {
|
|
35
|
+
function Speaker_proclaim() {
|
|
36
|
+
World_note(Speaker.phrase);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function World_note(phrase) {
|
|
40
|
+
World.log(phrase);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Speaker_proclaim();
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### RoleMethod Naming
|
|
48
|
+
|
|
49
|
+
- RoleMethods are functions within the Context scope, named `Role_method()`. Example: `Speaker_proclaim()`, `World_note()`
|
|
50
|
+
- Internal (private) RoleMethods, callable only by RoleMethods in the same Role, use a double underscore: `Role__method()`.
|
|
51
|
+
|
|
52
|
+
### Role Organization
|
|
53
|
+
|
|
54
|
+
- Use `//#region RoleName Role /////` and `//#endregion` comments to group RoleMethods by Role. This enables easy folding/unfolding of Roles in the editor.
|
|
55
|
+
|
|
56
|
+
### Type Annotations
|
|
57
|
+
|
|
58
|
+
- Use JSDoc for typing, and `@ts-check` on top if creating a new file.
|
|
59
|
+
- Use `@DCI-context` tag to mark Context functions.
|
|
60
|
+
- Clarify Role Contracts with explicit types inline.
|
|
61
|
+
- Prefer inline literal types over separate type declarations for Role Contracts.
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
## TypeScript DCI Examples
|
|
2
|
+
|
|
3
|
+
### AJAX form submit with dynamic error display
|
|
4
|
+
|
|
5
|
+
- Notable as a "one off" operation, nothing is returned from the Context function.
|
|
6
|
+
- Also demonstrates the basics of Context error handling by using a single try/catch around the System Operation part, to avoid errors leaking outside the RoleMethods.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
/**
|
|
10
|
+
* Submit a form and show error messages from the response.
|
|
11
|
+
* @DCI-context
|
|
12
|
+
*/
|
|
13
|
+
async function SubmitForm(e: SubmitEvent) {
|
|
14
|
+
if (!(e.target instanceof HTMLFormElement)) throw new Error("No form found.");
|
|
15
|
+
|
|
16
|
+
//#region Form Role ////////////////////
|
|
17
|
+
|
|
18
|
+
const Form: { action: string } = e.target;
|
|
19
|
+
|
|
20
|
+
async function Form_submit() {
|
|
21
|
+
// Role contract: Form.action
|
|
22
|
+
const response = await fetch(Form.action, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
body: new FormData(Form as HTMLFormElement),
|
|
25
|
+
});
|
|
26
|
+
const data = await response.json();
|
|
27
|
+
for (const error of data.errors ?? []) Messages_show(error); // Role interaction
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
|
|
32
|
+
//#region Messages Role ////////////////////
|
|
33
|
+
|
|
34
|
+
const Messages: Iterable<{
|
|
35
|
+
dataset: DOMStringMap;
|
|
36
|
+
style: CSSStyleDeclaration;
|
|
37
|
+
}> = e.target.querySelectorAll<HTMLElement>("[data-form-message]");
|
|
38
|
+
|
|
39
|
+
async function Messages_hide() {
|
|
40
|
+
Messages__set("none");
|
|
41
|
+
await Form_submit(); // Role interaction
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Messages_show(name: string) {
|
|
45
|
+
Messages__set("unset", name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Messages__set(display: string, name = "") {
|
|
49
|
+
for (const msg of Messages) {
|
|
50
|
+
if (name && msg.dataset.formMessage != name) continue;
|
|
51
|
+
msg.style.display = display;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//#endregion
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
console.log("Submit");
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
await Messages_hide(); // System operation
|
|
61
|
+
console.log("Done");
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error(e);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Session validation for SvelteKit and Drizzle
|
|
69
|
+
|
|
70
|
+
- Another "one off" operation, where ultimately the Request is modified to have valid or invalid session data.
|
|
71
|
+
- The Roles are defined in the *Context arguments*, so they will not have their common place before their RoleMethods, which they would if they were defined inside the Context.
|
|
72
|
+
- The System Operation (initial RoleMethod call) is started right away, as all Roles are defined in the Context arguments.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
/**
|
|
76
|
+
* Sets locals.user and locals.session on success, otherwise null.
|
|
77
|
+
* @DCI-context
|
|
78
|
+
*/
|
|
79
|
+
export async function ValidateSession(
|
|
80
|
+
Request: RequestEvent,
|
|
81
|
+
Session = db,
|
|
82
|
+
INVALIDATE = false
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
await Request_getTokenFromCookie();
|
|
85
|
+
|
|
86
|
+
//#region Request //////////////////////////////
|
|
87
|
+
|
|
88
|
+
async function Request_getTokenFromCookie() {
|
|
89
|
+
const token = Request.cookies.get(COOKIE_NAME);
|
|
90
|
+
|
|
91
|
+
if (!token) Request_clearSession();
|
|
92
|
+
else await Session_findByToken(token);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function Request_setSession(token: string, session: Session, user: User) {
|
|
96
|
+
Object.freeze(user);
|
|
97
|
+
Object.freeze(session);
|
|
98
|
+
|
|
99
|
+
Request.cookies.set(COOKIE_NAME, token, {
|
|
100
|
+
httpOnly: true,
|
|
101
|
+
sameSite: "lax",
|
|
102
|
+
expires: session.expiresAt,
|
|
103
|
+
path: "/",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
Request.locals.user = user;
|
|
107
|
+
Request.locals.session = session;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Request_clearSession(): void {
|
|
111
|
+
Request.cookies.set(COOKIE_NAME, "", {
|
|
112
|
+
httpOnly: true,
|
|
113
|
+
sameSite: "lax",
|
|
114
|
+
maxAge: 0,
|
|
115
|
+
path: "/",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
Request.locals.user = undefined;
|
|
119
|
+
Request.locals.session = undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//#region Session ////////////////////////////////////////
|
|
123
|
+
|
|
124
|
+
async function Session_findByToken(token: string) {
|
|
125
|
+
const sessionId = encodeHexLowerCase(
|
|
126
|
+
sha256(new TextEncoder().encode(token))
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const [result] = await Session.select({
|
|
130
|
+
user: userTable,
|
|
131
|
+
session: sessionTable,
|
|
132
|
+
})
|
|
133
|
+
.from(sessionTable)
|
|
134
|
+
.innerJoin(userTable, eq(sessionTable.userId, userTable.id))
|
|
135
|
+
.where(eq(sessionTable.id, sessionId))
|
|
136
|
+
.limit(1);
|
|
137
|
+
|
|
138
|
+
if (!result) Request_clearSession();
|
|
139
|
+
else await Session_checkExpiryDate(token, result.session, result.user);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function Session_checkExpiryDate(
|
|
143
|
+
token: string,
|
|
144
|
+
session: Session,
|
|
145
|
+
user: User
|
|
146
|
+
) {
|
|
147
|
+
if (INVALIDATE || Date.now() >= session.expiresAt.getTime()) {
|
|
148
|
+
await Session.delete(sessionTable).where(eq(sessionTable.id, session.id));
|
|
149
|
+
Request_clearSession();
|
|
150
|
+
} else {
|
|
151
|
+
await Session_refreshExpiryDate(token, session, user);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function Session_refreshExpiryDate(
|
|
156
|
+
token: string,
|
|
157
|
+
session: Session,
|
|
158
|
+
user: User
|
|
159
|
+
) {
|
|
160
|
+
if (
|
|
161
|
+
Date.now() >=
|
|
162
|
+
session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * (EXPIRY_DAYS / 2)
|
|
163
|
+
) {
|
|
164
|
+
session.expiresAt = new Date(
|
|
165
|
+
Date.now() + 1000 * 60 * 60 * 24 * EXPIRY_DAYS
|
|
166
|
+
);
|
|
167
|
+
await Session
|
|
168
|
+
.update(sessionTable)
|
|
169
|
+
.set({
|
|
170
|
+
expiresAt: session.expiresAt,
|
|
171
|
+
})
|
|
172
|
+
.where(eq(sessionTable.id, session.id));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
Request_setSession(token, session, user);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### A book borrowing machine at a public library
|
|
181
|
+
|
|
182
|
+
- Notable as it returns an object from the Context, similar to a class with public methods.
|
|
183
|
+
- The `Screen` and `Printer` Roles are defined in the *Context arguments*, so they will not have their common place before their RoleMethods, which they would if they were defined inside the Context, as the other Roles are.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { Display, type ScreenState } from "$lib/assets/screen/screenStates";
|
|
187
|
+
import { title } from "$lib/data/libraryItem";
|
|
188
|
+
import { cards, library, loans } from "$lib/library";
|
|
189
|
+
import { hash } from "$lib/utils";
|
|
190
|
+
import { BorrowItem } from "./borrowItem";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A book borrowing machine at a public library.
|
|
194
|
+
* @DCI-context
|
|
195
|
+
*/
|
|
196
|
+
export function LibraryMachine(
|
|
197
|
+
Screen: {
|
|
198
|
+
display: (state: ScreenState) => void;
|
|
199
|
+
currentState: () => ScreenState;
|
|
200
|
+
},
|
|
201
|
+
Printer: {
|
|
202
|
+
print: (line: string) => void;
|
|
203
|
+
}
|
|
204
|
+
) {
|
|
205
|
+
//#region Borrower /////
|
|
206
|
+
|
|
207
|
+
let Borrower: {
|
|
208
|
+
"@id": string;
|
|
209
|
+
"@type": "Person";
|
|
210
|
+
items: { id: string; title: string; expires: Date }[];
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function Borrower_isLoggedIn() {
|
|
214
|
+
// A getter is ok if it is descriptive beyond "get" and returns a boolean
|
|
215
|
+
return !!Borrower["@id"];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function Borrower_login(user: Pick<typeof Borrower, "@id" | "@type">) {
|
|
219
|
+
rebind(user["@id"]);
|
|
220
|
+
Screen_displayItems(Borrower.items);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @param forced Whether the logout was forced by the user (e.g. card removed)
|
|
225
|
+
*/
|
|
226
|
+
function Borrower_logout(forced: boolean, printItems: boolean) {
|
|
227
|
+
// Need to print before rebinding, as it will clear the items
|
|
228
|
+
if (printItems) Printer_printReceipt(Borrower.items);
|
|
229
|
+
|
|
230
|
+
if (Borrower_isLoggedIn()) rebind(undefined);
|
|
231
|
+
Screen_displayThankYou(forced);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function Borrower_borrowItem(itemId: string | undefined) {
|
|
235
|
+
// TODO: Built-in security (assertions) for required login
|
|
236
|
+
if (!Borrower_isLoggedIn() || !itemId) return;
|
|
237
|
+
|
|
238
|
+
if (Borrower.items.find((item) => item.id === itemId)) return;
|
|
239
|
+
|
|
240
|
+
// Call nested context
|
|
241
|
+
const loan = BorrowItem(library, Borrower, { "@id": itemId }, loans);
|
|
242
|
+
|
|
243
|
+
// TODO: Error handling (logging) for expected errors
|
|
244
|
+
if (loan instanceof Error) return Screen_displayError(loan);
|
|
245
|
+
|
|
246
|
+
Borrower.items.push({
|
|
247
|
+
id: loan.object["@id"],
|
|
248
|
+
title: title(loan.object),
|
|
249
|
+
expires: loan.endTime,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
Screen_displayItems(Borrower.items);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
//#endregion
|
|
256
|
+
|
|
257
|
+
//#region CardReader /////
|
|
258
|
+
|
|
259
|
+
const CardReader: { currentId: string; attempts: number } = {
|
|
260
|
+
currentId: "",
|
|
261
|
+
attempts: 0,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
function CardReader_cardScanned(id: string | undefined) {
|
|
265
|
+
if (CardReader.currentId == id) return;
|
|
266
|
+
|
|
267
|
+
if (!id) {
|
|
268
|
+
// Card removed or missing
|
|
269
|
+
if (CardReader.currentId) Borrower_logout(true, false);
|
|
270
|
+
} else {
|
|
271
|
+
// Card scanned
|
|
272
|
+
if (!Borrower_isLoggedIn()) {
|
|
273
|
+
// New card
|
|
274
|
+
Screen_displayEnterPIN(0);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
CardReader.currentId = id ?? "";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function CardReader_resetAttempts() {
|
|
282
|
+
CardReader.attempts = 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function CardReader_validatePIN(pin: string[]) {
|
|
286
|
+
Library_validateCard(CardReader.currentId, pin);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function CardReader_PINfailed() {
|
|
290
|
+
// TODO: Force remove card after 3 failed attempts
|
|
291
|
+
Screen_displayEnterPIN(++CardReader.attempts);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
//#endregion
|
|
295
|
+
|
|
296
|
+
//#region Library /////
|
|
297
|
+
|
|
298
|
+
const Library = {
|
|
299
|
+
cards,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
function Library_validateCard(cardId: string, pin: string[]) {
|
|
303
|
+
const card = Library.cards.find((card) => card["@id"] === cardId);
|
|
304
|
+
if (card && card.identifier === hash(pin.join(""))) {
|
|
305
|
+
Borrower_login(card._owner);
|
|
306
|
+
} else {
|
|
307
|
+
CardReader_PINfailed();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
//#endregion
|
|
312
|
+
|
|
313
|
+
//#region Screen /////
|
|
314
|
+
|
|
315
|
+
function Screen_displayWelcome() {
|
|
316
|
+
Screen.display({ display: Display.Welcome });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function Screen_displayEnterPIN(attempts: number) {
|
|
320
|
+
Screen.display({ display: Display.EnterPIN, attempts });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function Screen_displayItems(items: { title: string; expires: Date }[]) {
|
|
324
|
+
Screen.display({ display: Display.Items, items });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function Screen_displayThankYou(forced: boolean) {
|
|
328
|
+
if (forced && Screen.currentState().display === Display.ThankYou) {
|
|
329
|
+
Screen_displayWelcome();
|
|
330
|
+
} else {
|
|
331
|
+
Screen.display({ display: Display.ThankYou });
|
|
332
|
+
if (forced) Screen__displayNext({ display: Display.Welcome });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function Screen_displayError(error: Error) {
|
|
337
|
+
// Log out user
|
|
338
|
+
rebind(undefined);
|
|
339
|
+
Screen.display({ display: Display.Error, error });
|
|
340
|
+
Screen__displayNext({ display: Display.Welcome }, 10000);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function Screen__displayNext(nextState: ScreenState, delay = 5000) {
|
|
344
|
+
const currentState = Screen.currentState();
|
|
345
|
+
setTimeout(() => {
|
|
346
|
+
if (currentState === Screen.currentState()) Screen.display(nextState);
|
|
347
|
+
}, delay);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
//#endregion
|
|
351
|
+
|
|
352
|
+
//#region Printer /////
|
|
353
|
+
|
|
354
|
+
async function Printer_printReceipt(
|
|
355
|
+
items: { title: string; expires: Date }[]
|
|
356
|
+
) {
|
|
357
|
+
if (items.length) {
|
|
358
|
+
await Printer__printLine(new Date().toISOString().slice(0, 10));
|
|
359
|
+
await Printer__printLine("");
|
|
360
|
+
for (const item of items) {
|
|
361
|
+
await Printer__printLine(item.title);
|
|
362
|
+
await Printer__printLine(
|
|
363
|
+
"Return on " + item.expires.toISOString().slice(0, 10)
|
|
364
|
+
);
|
|
365
|
+
await Printer__printLine("");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function Printer__printLine(line: string) {
|
|
371
|
+
Printer.print(line);
|
|
372
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
//#endregion
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Reset the Context state, rebind to a new user or undefined (not logged in).
|
|
379
|
+
*/
|
|
380
|
+
function rebind(userId: string | undefined) {
|
|
381
|
+
Borrower = { "@id": userId ?? "", "@type": "Person", items: [] };
|
|
382
|
+
CardReader_resetAttempts();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
{
|
|
386
|
+
// Context start
|
|
387
|
+
rebind(undefined);
|
|
388
|
+
Screen_displayWelcome();
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
cardScanned(id: string | undefined) {
|
|
392
|
+
CardReader_cardScanned(id);
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
itemScanned(id: string | undefined) {
|
|
396
|
+
Borrower_borrowItem(id);
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
pinEntered(pin: string[]) {
|
|
400
|
+
CardReader_validatePIN(pin);
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
finish(printReceipt: boolean) {
|
|
404
|
+
Borrower_logout(false, printReceipt);
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
## TypeScript-Specific Implementation
|
|
2
|
+
|
|
3
|
+
### Context Implementation
|
|
4
|
+
|
|
5
|
+
- A Context is a function annotated with `@DCI-context`. If that doesn't exist, _do not_ apply DCI.
|
|
6
|
+
- Contexts and their RoleMethods can be async functions when needed.
|
|
7
|
+
|
|
8
|
+
### Role Contracts
|
|
9
|
+
|
|
10
|
+
- Use literal types as Role Contracts, so the code can be understood without deeper type knowledge. Example:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const Form: { action: string } = event.target;
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- EXCEPTION: If the types are well-known, like the JavaScript Web APIs, you can reference them directly (e.g., `Page`, `HTMLElement`). Example:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const Page: Page = await Browser.newPage();
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- If an object is passed to the Context function that fits the mental model of a Context Role, the Role should be defined from it with the Role Contract as the parameter type. This is the ONLY case RoleMethods should exist in the Context without their Role defined immediately before them. Example:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
/**
|
|
26
|
+
* @DCI-context
|
|
27
|
+
* A speaker proclaims something to the world, that dutifully notes it
|
|
28
|
+
*/
|
|
29
|
+
function HelloWorld(
|
|
30
|
+
Speaker: { phrase: string },
|
|
31
|
+
World: { log: (msg: unknown) => void },
|
|
32
|
+
) {
|
|
33
|
+
function Speaker_proclaim() {
|
|
34
|
+
World_note(Speaker.phrase);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function World_note(phrase: string) {
|
|
38
|
+
World.log(phrase);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Speaker_proclaim();
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### RoleMethod Naming
|
|
46
|
+
|
|
47
|
+
- RoleMethods are functions within the Context scope, named `Role_method()`. Example: `Speaker_proclaim()`, `World_note()`
|
|
48
|
+
- Internal (private) RoleMethods, callable only by RoleMethods in the same Role, use a double underscore: `Role__method()`.
|
|
49
|
+
|
|
50
|
+
### Role Organization
|
|
51
|
+
|
|
52
|
+
- Use `//#region RoleName Role /////` and `//#endregion` comments to group RoleMethods by Role.
|
|
53
|
+
- This enables easy folding/unfolding of Roles in the editor.
|
|
54
|
+
|
|
55
|
+
### Type Annotations
|
|
56
|
+
|
|
57
|
+
- Use `@DCI-context` JSDoc tag to mark Context functions.
|
|
58
|
+
- Clarify Role Contracts with explicit types inline.
|
|
59
|
+
- Prefer inline literal types over separate type declarations for Role Contracts.
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dci-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP Server generating code that adheres to DCI architecture principles.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dci-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
15
|
+
"zod": "^4.3.6"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"typescript": "^5.0.0"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"start": "node --experimental-strip-types src/index.ts",
|
|
24
|
+
"dev": "pnpm build && pnpm link",
|
|
25
|
+
"inspector": "pnpx @modelcontextprotocol/inspector dci-mcp"
|
|
26
|
+
}
|
|
27
|
+
}
|