millas 0.2.12-beta-1 → 0.2.13-beta

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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Token pricing per model — USD per 1M tokens
5
+ // Updated: 2025. Check provider pricing pages for latest rates.
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ const PRICING = {
9
+ // ── Anthropic ───────────────────────────────────────────────────────────────
10
+ 'claude-opus-4-5': { input: 15.00, output: 75.00 },
11
+ 'claude-sonnet-4-20250514': { input: 3.00, output: 15.00 },
12
+ 'claude-sonnet-4-5': { input: 3.00, output: 15.00 },
13
+ 'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00 },
14
+ 'claude-3-7-sonnet-20250219': { input: 3.00, output: 15.00 },
15
+ 'claude-3-5-sonnet-20241022': { input: 3.00, output: 15.00 },
16
+ 'claude-3-5-haiku-20241022': { input: 0.80, output: 4.00 },
17
+ 'claude-3-opus-20240229': { input: 15.00, output: 75.00 },
18
+
19
+ // ── OpenAI ──────────────────────────────────────────────────────────────────
20
+ 'gpt-4o': { input: 2.50, output: 10.00 },
21
+ 'gpt-4o-mini': { input: 0.15, output: 0.60 },
22
+ 'gpt-4-turbo': { input: 10.00, output: 30.00 },
23
+ 'gpt-4': { input: 30.00, output: 60.00 },
24
+ 'gpt-3.5-turbo': { input: 0.50, output: 1.50 },
25
+ 'o1': { input: 15.00, output: 60.00 },
26
+ 'o1-mini': { input: 3.00, output: 12.00 },
27
+ 'o3-mini': { input: 1.10, output: 4.40 },
28
+
29
+ // ── Gemini ──────────────────────────────────────────────────────────────────
30
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
31
+ 'gemini-2.5-flash': { input: 0.15, output: 0.60 },
32
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40 },
33
+ 'gemini-2.0-flash-lite': { input: 0.075, output: 0.30 },
34
+ 'gemini-1.5-pro': { input: 1.25, output: 5.00 },
35
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30 },
36
+
37
+ // ── Groq ────────────────────────────────────────────────────────────────────
38
+ 'llama-3.3-70b-versatile': { input: 0.59, output: 0.79 },
39
+ 'llama-3.1-70b-versatile': { input: 0.59, output: 0.79 },
40
+ 'llama-3.1-8b-instant': { input: 0.05, output: 0.08 },
41
+ 'mixtral-8x7b-32768': { input: 0.24, output: 0.24 },
42
+
43
+ // ── Mistral ──────────────────────────────────────────────────────────────────
44
+ 'mistral-large-latest': { input: 2.00, output: 6.00 },
45
+ 'mistral-small-latest': { input: 0.20, output: 0.60 },
46
+ 'open-mistral-7b': { input: 0.25, output: 0.25 },
47
+
48
+ // ── DeepSeek ─────────────────────────────────────────────────────────────────
49
+ 'deepseek-chat': { input: 0.27, output: 1.10 },
50
+ 'deepseek-reasoner': { input: 0.55, output: 2.19 },
51
+
52
+ // ── xAI ─────────────────────────────────────────────────────────────────────
53
+ 'grok-2': { input: 2.00, output: 10.00 },
54
+ 'grok-2-mini': { input: 0.20, output: 1.00 },
55
+ };
56
+
57
+ // ─────────────────────────────────────────────────────────────────────────────
58
+ // CostCalculator
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+
61
+ class CostCalculator {
62
+ /**
63
+ * Calculate cost for a completed response.
64
+ *
65
+ * const cost = CostCalculator.forResponse(response);
66
+ * cost.input // 0.0003
67
+ * cost.output // 0.0015
68
+ * cost.total // 0.0018
69
+ * cost.currency // 'USD'
70
+ * cost.formatted // '$0.0018'
71
+ *
72
+ * @param {AIResponse} response
73
+ * @returns {{ input, output, total, currency, formatted } | null}
74
+ */
75
+ static forResponse(response) {
76
+ return CostCalculator.calculate(
77
+ response.model,
78
+ response.inputTokens,
79
+ response.outputTokens
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Calculate cost given model and token counts.
85
+ *
86
+ * @param {string} model
87
+ * @param {number} inputTokens
88
+ * @param {number} outputTokens
89
+ * @returns {{ input, output, total, currency, formatted } | null}
90
+ */
91
+ static calculate(model, inputTokens, outputTokens) {
92
+ const pricing = CostCalculator._lookup(model);
93
+ if (!pricing) return null;
94
+
95
+ const input = (inputTokens / 1_000_000) * pricing.input;
96
+ const output = (outputTokens / 1_000_000) * pricing.output;
97
+ const total = input + output;
98
+
99
+ return {
100
+ input: parseFloat(input.toFixed(6)),
101
+ output: parseFloat(output.toFixed(6)),
102
+ total: parseFloat(total.toFixed(6)),
103
+ currency: 'USD',
104
+ formatted: `$${total.toFixed(4)}`,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Estimate cost for a prompt before sending it.
110
+ * Uses a rough character-to-token ratio (1 token ≈ 4 chars).
111
+ *
112
+ * const est = AI.estimateCost('My long prompt here...', 'claude-sonnet-4-20250514');
113
+ * est.estimated // { input: 0.00003, output: 0.00015, total: 0.00018 }
114
+ * est.note // 'Estimate only. Output tokens unknown.'
115
+ *
116
+ * @param {string} prompt
117
+ * @param {string} model
118
+ * @param {number} [expectedOutputTokens=500]
119
+ */
120
+ static estimate(prompt, model, expectedOutputTokens = 500) {
121
+ const inputTokens = Math.ceil(prompt.length / 4);
122
+ const cost = CostCalculator.calculate(model, inputTokens, expectedOutputTokens);
123
+ if (!cost) return { estimated: null, note: `No pricing data for model: ${model}` };
124
+ return {
125
+ estimated: cost,
126
+ inputTokens,
127
+ outputTokens: expectedOutputTokens,
128
+ note: 'Estimate only. Output tokens are approximate.',
129
+ };
130
+ }
131
+
132
+ static _lookup(model) {
133
+ if (!model) return null;
134
+ // Exact match
135
+ if (PRICING[model]) return PRICING[model];
136
+ // Prefix match — handles versioned model strings like 'claude-3-5-sonnet-20241022-v2'
137
+ for (const key of Object.keys(PRICING)) {
138
+ if (model.startsWith(key) || key.startsWith(model.split('-').slice(0, 4).join('-'))) {
139
+ return PRICING[key];
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /** Check if pricing data exists for a model. */
146
+ static hasPricing(model) { return !!CostCalculator._lookup(model); }
147
+
148
+ /** List all models with known pricing. */
149
+ static supportedModels() { return Object.keys(PRICING); }
150
+ }
151
+
152
+ module.exports = { PRICING, CostCalculator };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Provider-native tools — executed by the AI provider, not your app
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ /**
8
+ * WebSearch — let the model search the web in real-time.
9
+ * Supported: Anthropic, OpenAI, Gemini
10
+ *
11
+ * AI.tools([new WebSearch()]).generate('What happened in tech today?')
12
+ * AI.tools([new WebSearch().max(5).allow(['techcrunch.com'])]).generate('...')
13
+ */
14
+ class WebSearch {
15
+ constructor() {
16
+ this._max = null;
17
+ this._domains = [];
18
+ this._location = null;
19
+ this._isProvider = true;
20
+ this.name = 'web_search';
21
+ this.description = 'Search the web for real-time information.';
22
+ }
23
+
24
+ /** Max number of searches the model may perform. */
25
+ max(n) { this._max = n; return this; }
26
+
27
+ /** Restrict results to specific domains. */
28
+ allow(domains) { this._domains = domains; return this; }
29
+
30
+ /** Bias results toward a location. */
31
+ location({ city, region, country } = {}) {
32
+ this._location = { city, region, country };
33
+ return this;
34
+ }
35
+
36
+ toProviderSchema(provider) {
37
+ if (provider === 'anthropic') {
38
+ const tool = { type: 'web_search_20250305', name: 'web_search' };
39
+ if (this._max) tool.max_uses = this._max;
40
+ if (this._domains?.length) tool.allowed_domains = this._domains;
41
+ return tool;
42
+ }
43
+ if (provider === 'openai') {
44
+ const tool = { type: 'web_search_preview' };
45
+ if (this._location) tool.search_context_size = 'medium';
46
+ if (this._domains?.length) tool.user_location = this._location;
47
+ return tool;
48
+ }
49
+ if (provider === 'gemini') {
50
+ return { google_search: {} };
51
+ }
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * WebFetch — let the model fetch and read web pages.
58
+ * Supported: Anthropic, Gemini
59
+ *
60
+ * AI.tools([new WebFetch()]).generate('Summarize https://example.com/page')
61
+ */
62
+ class WebFetch {
63
+ constructor() {
64
+ this._max = null;
65
+ this._domains = [];
66
+ this._isProvider = true;
67
+ this.name = 'web_fetch';
68
+ this.description = 'Fetch and read the content of web pages.';
69
+ }
70
+
71
+ max(n) { this._max = n; return this; }
72
+ allow(domains) { this._domains = domains; return this; }
73
+
74
+ toProviderSchema(provider) {
75
+ if (provider === 'anthropic') {
76
+ const tool = { type: 'web_search_20250305', name: 'web_search' };
77
+ if (this._max) tool.max_uses = this._max;
78
+ if (this._domains?.length) tool.allowed_domains = this._domains;
79
+ return tool;
80
+ }
81
+ if (provider === 'gemini') return { url_context: {} };
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * FileSearch — search through files in vector stores.
88
+ * Supported: OpenAI, Gemini
89
+ *
90
+ * AI.tools([new FileSearch({ stores: ['store_abc'] })]).generate('...')
91
+ */
92
+ class FileSearch {
93
+ constructor({ stores = [], where = null } = {}) {
94
+ this._stores = stores;
95
+ this._where = where;
96
+ this._isProvider = true;
97
+ this.name = 'file_search';
98
+ this.description = 'Search through files in vector stores.';
99
+ }
100
+
101
+ toProviderSchema(provider) {
102
+ if (provider === 'openai') {
103
+ const tool = { type: 'file_search', vector_store_ids: this._stores };
104
+ if (this._where) tool.filters = this._where;
105
+ return tool;
106
+ }
107
+ if (provider === 'gemini') {
108
+ return { retrieval: { vertex_ai_search: { datastore: this._stores[0] } } };
109
+ }
110
+ return null;
111
+ }
112
+ }
113
+
114
+ module.exports = { WebSearch, WebFetch, FileSearch };
@@ -0,0 +1,356 @@
1
+ 'use strict';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // AIMessage — a single message in a conversation
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ class AIMessage {
8
+ /**
9
+ * @param {'user'|'assistant'|'system'|'tool'} role
10
+ * @param {string|Array} content — string or array of content parts
11
+ * @param {object} [meta] — tool_call_id, name, usage, etc.
12
+ */
13
+ constructor(role, content, meta = {}) {
14
+ this.role = role;
15
+ this.content = content;
16
+ this.meta = meta;
17
+ }
18
+
19
+ static user(content) { return new AIMessage('user', content); }
20
+ static assistant(content) { return new AIMessage('assistant', content); }
21
+ static system(content) { return new AIMessage('system', content); }
22
+ static tool(id, name, content) {
23
+ return new AIMessage('tool', content, { tool_call_id: id, name });
24
+ }
25
+
26
+ toJSON() {
27
+ const base = { role: this.role, content: this.content };
28
+ if (this.meta.tool_call_id) {
29
+ base.tool_call_id = this.meta.tool_call_id;
30
+ base.name = this.meta.name;
31
+ }
32
+ return base;
33
+ }
34
+ }
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // AIResponse — structured result from any provider
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ class AIResponse {
41
+ constructor({
42
+ text, role = 'assistant', model, provider,
43
+ inputTokens = 0, outputTokens = 0,
44
+ toolCalls = [], finishReason = 'stop',
45
+ raw = null,
46
+ }) {
47
+ this.text = text || '';
48
+ this.role = role;
49
+ this.model = model;
50
+ this.provider = provider;
51
+ this.inputTokens = inputTokens;
52
+ this.outputTokens = outputTokens;
53
+ this.totalTokens = inputTokens + outputTokens;
54
+ this.toolCalls = toolCalls; // [{ id, name, arguments }]
55
+ this.finishReason = finishReason;
56
+ this.raw = raw; // original provider response
57
+ }
58
+
59
+ /** True when the model wants to call tools. */
60
+ get hasToolCalls() { return this.toolCalls.length > 0; }
61
+
62
+ /** True when the model stopped naturally. */
63
+ get isComplete() { return this.finishReason === 'stop'; }
64
+
65
+ /** True when the model hit a token limit. */
66
+ get isTokenLimited() { return this.finishReason === 'length'; }
67
+
68
+ /** Cast to the assistant AIMessage to append to a thread. */
69
+ toMessage() {
70
+ const content = this.hasToolCalls
71
+ ? [{ type: 'text', text: this.text }, ...this.toolCalls.map(tc => ({
72
+ type: 'tool_use', id: tc.id, name: tc.name, input: tc.arguments,
73
+ }))]
74
+ : this.text;
75
+ return new AIMessage('assistant', content);
76
+ }
77
+
78
+ toString() { return this.text; }
79
+ }
80
+
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ // AIStreamEvent — typed events emitted during streaming
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ class AIStreamEvent {
86
+ constructor(type, data) { this.type = type; this.data = data; }
87
+
88
+ static delta(text) { return new AIStreamEvent('delta', { text }); }
89
+ static thinking(text) { return new AIStreamEvent('thinking', { text }); }
90
+ static toolCall(tc) { return new AIStreamEvent('tool_call', tc); }
91
+ static complete(response) { return new AIStreamEvent('complete', response); }
92
+ static error(err) { return new AIStreamEvent('error', { error: err }); }
93
+ }
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+ // Tool — define a callable tool the model can invoke
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+
99
+ class Tool {
100
+ /**
101
+ * @param {string} name
102
+ * @param {string} description
103
+ * @param {object} schema — JSON Schema object for parameters
104
+ * @param {function} handler — async (args) => result
105
+ */
106
+ constructor(name, description, schema, handler) {
107
+ this.name = name;
108
+ this.description = description;
109
+ this.schema = schema;
110
+ this.handler = handler;
111
+ }
112
+
113
+ /**
114
+ * Fluent factory:
115
+ *
116
+ * Tool.define('get_weather')
117
+ * .description('Get the weather for a city')
118
+ * .parameters({
119
+ * type: 'object',
120
+ * properties: {
121
+ * city: { type: 'string', description: 'City name' },
122
+ * units: { type: 'string', enum: ['celsius', 'fahrenheit'] },
123
+ * },
124
+ * required: ['city'],
125
+ * })
126
+ * .handle(async ({ city, units }) => {
127
+ * return await WeatherService.get(city, units);
128
+ * })
129
+ */
130
+ static define(name) { return new ToolBuilder(name); }
131
+
132
+ toProviderSchema() {
133
+ return { name: this.name, description: this.description, input_schema: this.schema };
134
+ }
135
+
136
+ toOpenAISchema() {
137
+ return {
138
+ type: 'function',
139
+ function: { name: this.name, description: this.description, parameters: this.schema },
140
+ };
141
+ }
142
+ }
143
+
144
+ class ToolBuilder {
145
+ constructor(name) {
146
+ this._name = name;
147
+ this._description = '';
148
+ this._schema = { type: 'object', properties: {}, required: [] };
149
+ this._handler = null;
150
+ }
151
+ description(d) { this._description = d; return this; }
152
+ parameters(s) { this._schema = s; return this; }
153
+ handle(fn) { this._handler = fn; return this; }
154
+ build() {
155
+ return new Tool(this._name, this._description, this._schema, this._handler);
156
+ }
157
+ }
158
+
159
+ // ─────────────────────────────────────────────────────────────────────────────
160
+ // Thread — conversation memory manager
161
+ // ─────────────────────────────────────────────────────────────────────────────
162
+
163
+ class Thread {
164
+ constructor(systemPrompt = null) {
165
+ this._messages = [];
166
+ this._systemPrompt = systemPrompt;
167
+ this._maxMessages = null; // null = unlimited
168
+ this._summaryFn = null; // async (messages) => summaryString
169
+ }
170
+
171
+ /** Set a new or updated system prompt. */
172
+ system(prompt) { this._systemPrompt = prompt; return this; }
173
+
174
+ /** Limit history to the last N messages (sliding window). */
175
+ limit(n) { this._maxMessages = n; return this; }
176
+
177
+ /**
178
+ * Provide a summarisation function. When the thread exceeds limit(),
179
+ * older messages are collapsed into a summary instead of dropped.
180
+ *
181
+ * thread.summariseWith(async (msgs) => {
182
+ * const res = await AI.text(`Summarise this: ${msgs.map(m=>m.content).join('\n')}`);
183
+ * return res.text;
184
+ * });
185
+ */
186
+ summariseWith(fn) { this._summaryFn = fn; return this; }
187
+
188
+ add(message) {
189
+ this._messages.push(message);
190
+ return this;
191
+ }
192
+
193
+ addUser(content) { return this.add(AIMessage.user(content)); }
194
+ addAssistant(content) { return this.add(AIMessage.assistant(content)); }
195
+
196
+ /** Messages formatted for the provider, respecting limit. */
197
+ async toArray() {
198
+ let msgs = [...this._messages];
199
+
200
+ if (this._maxMessages && msgs.length > this._maxMessages) {
201
+ const overflow = msgs.slice(0, msgs.length - this._maxMessages);
202
+ msgs = msgs.slice(msgs.length - this._maxMessages);
203
+
204
+ if (this._summaryFn) {
205
+ const summary = await this._summaryFn(overflow);
206
+ msgs.unshift(AIMessage.system(`Earlier conversation summary: ${summary}`));
207
+ }
208
+ }
209
+
210
+ return msgs.map(m => m.toJSON());
211
+ }
212
+
213
+ get length() { return this._messages.length; }
214
+
215
+ clear() { this._messages = []; return this; }
216
+
217
+ /** Last assistant message text. */
218
+ get lastReply() {
219
+ const last = [...this._messages].reverse().find(m => m.role === 'assistant');
220
+ return last ? (typeof last.content === 'string' ? last.content : last.content?.[0]?.text || '') : null;
221
+ }
222
+ }
223
+
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+ // Prompt — template with variable substitution
226
+ // ─────────────────────────────────────────────────────────────────────────────
227
+
228
+ class Prompt {
229
+ /**
230
+ * @param {string} template — use {{variable}} syntax
231
+ *
232
+ * Prompt.make('Summarise this in {{language}}: {{text}}')
233
+ * .with({ language: 'French', text: article })
234
+ * .toString()
235
+ */
236
+ constructor(template) {
237
+ this._template = template;
238
+ this._vars = {};
239
+ }
240
+
241
+ static make(template) { return new Prompt(template); }
242
+
243
+ with(vars) { Object.assign(this._vars, vars); return this; }
244
+
245
+ toString() {
246
+ return this._template.replace(/\{\{(\w+)\}\}/g, (_, k) =>
247
+ Object.prototype.hasOwnProperty.call(this._vars, k) ? String(this._vars[k]) : `{{${k}}}`
248
+ );
249
+ }
250
+ }
251
+
252
+ // ─────────────────────────────────────────────────────────────────────────────
253
+ // Schema — structured output enforcement
254
+ // ─────────────────────────────────────────────────────────────────────────────
255
+
256
+ class Schema {
257
+ /**
258
+ * Define expected output structure. The AI manager will:
259
+ * 1. Inject the schema into the prompt
260
+ * 2. Parse and validate the response JSON
261
+ * 3. Retry once if parsing fails
262
+ *
263
+ * Schema.define({
264
+ * name: { type: 'string' },
265
+ * confidence: { type: 'number', min: 0, max: 1 },
266
+ * tags: { type: 'array' },
267
+ * })
268
+ */
269
+ constructor(shape) {
270
+ this._shape = shape;
271
+ }
272
+
273
+ static define(shape) { return new Schema(shape); }
274
+
275
+ /** JSON Schema representation passed to the provider. */
276
+ toJSONSchema() {
277
+ const props = {};
278
+ const required = [];
279
+ for (const [key, def] of Object.entries(this._shape)) {
280
+ props[key] = { type: def.type, description: def.description || '' };
281
+ if (def.enum) props[key].enum = def.enum;
282
+ if (def.items) props[key].items = def.items;
283
+ if (def.required !== false) required.push(key);
284
+ }
285
+ return { type: 'object', properties: props, required };
286
+ }
287
+
288
+ /** Validate and cast a parsed object against the shape. */
289
+ validate(obj) {
290
+ const result = {};
291
+ const errors = [];
292
+ for (const [key, def] of Object.entries(this._shape)) {
293
+ const val = obj[key];
294
+ if (val === undefined || val === null) {
295
+ if (def.required !== false) errors.push(`Missing field: ${key}`);
296
+ result[key] = def.default ?? null;
297
+ continue;
298
+ }
299
+ if (def.type === 'number') {
300
+ const n = Number(val);
301
+ if (isNaN(n)) { errors.push(`${key}: expected number`); continue; }
302
+ if (def.min !== undefined && n < def.min) errors.push(`${key}: below min ${def.min}`);
303
+ if (def.max !== undefined && n > def.max) errors.push(`${key}: above max ${def.max}`);
304
+ result[key] = n;
305
+ } else {
306
+ result[key] = val;
307
+ }
308
+ }
309
+ if (errors.length) throw new AIStructuredOutputError(errors.join('; '), obj);
310
+ return result;
311
+ }
312
+ }
313
+
314
+ // ─────────────────────────────────────────────────────────────────────────────
315
+ // Error types
316
+ // ─────────────────────────────────────────────────────────────────────────────
317
+
318
+ class AIError extends Error {
319
+ constructor(message, provider, cause = null) {
320
+ super(message);
321
+ this.name = 'AIError';
322
+ this.provider = provider;
323
+ this.cause = cause;
324
+ }
325
+ }
326
+
327
+ class AIRateLimitError extends AIError {
328
+ constructor(provider, retryAfter = null) {
329
+ super(`Rate limit exceeded on provider "${provider}"`, provider);
330
+ this.name = 'AIRateLimitError';
331
+ this.retryAfter = retryAfter;
332
+ }
333
+ }
334
+
335
+ class AIStructuredOutputError extends Error {
336
+ constructor(message, raw) {
337
+ super(`Structured output validation failed: ${message}`);
338
+ this.name = 'AIStructuredOutputError';
339
+ this.raw = raw;
340
+ }
341
+ }
342
+
343
+ class AIProviderError extends AIError {
344
+ constructor(provider, message, statusCode = null) {
345
+ super(message, provider);
346
+ this.name = 'AIProviderError';
347
+ this.statusCode = statusCode;
348
+ }
349
+ }
350
+
351
+ module.exports = {
352
+ AIMessage, AIResponse, AIStreamEvent,
353
+ Tool, ToolBuilder,
354
+ Thread, Prompt, Schema,
355
+ AIError, AIRateLimitError, AIStructuredOutputError, AIProviderError,
356
+ };
package/src/auth/Auth.js CHANGED
@@ -80,7 +80,9 @@ class Auth {
80
80
  if (existing) throw new HttpError(422, 'Email already in use');
81
81
 
82
82
  const hashed = await Hasher.make(data.password);
83
- return this._UserModel.create({ ...data, password: hashed });
83
+ // is_active defaults true set explicitly so it is always present
84
+ // regardless of whether the model field has a DB default.
85
+ return this._UserModel.create({ is_active: true, ...data, password: hashed });
84
86
  }
85
87
 
86
88
  /**
@@ -98,8 +100,22 @@ class Auth {
98
100
  const user = await this._UserModel.findBy('email', email);
99
101
  if (!user) throw new HttpError(401, 'Invalid credentials');
100
102
 
103
+ // Check password before is_active — avoids leaking whether the account exists
101
104
  const ok = await Hasher.check(password, user.password);
102
- if (!ok) throw new HttpError(401, 'Invalid credentials');
105
+ if (!ok) throw new HttpError(401, 'Invalid credentials');
106
+
107
+ // Django: 'Please enter the correct email and password for a staff account.
108
+ // Note that both fields may be case-sensitive.'
109
+ // Millas matches this — inactive check is after password so error message
110
+ // doesn't reveal which condition failed to a brute-force attacker.
111
+ if (user.is_active === false || user.is_active === 0) {
112
+ throw new HttpError(401, 'This account is inactive.');
113
+ }
114
+
115
+ // Record last login (fire-and-forget — never block the login response)
116
+ try {
117
+ await this._UserModel.where('id', user.id).update({ last_login: new Date().toISOString() });
118
+ } catch { /* non-fatal — table may not have last_login yet */ }
103
119
 
104
120
  const payload = this._buildTokenPayload(user);
105
121
  const token = this._jwt.sign(payload);