diff --git a/.jules/bolt.md b/.jules/bolt.md
index b08b203a..8bc5deb5 100644
--- a/.jules/bolt.md
+++ b/.jules/bolt.md
@@ -4,3 +4,9 @@
## 2026-07-12 - Optimize renderTaskRow DOM allocations
**Learning:** Caching unattached template nodes and instantiating them via `.cloneNode(false)` reduces DOM instantiation overhead in O(N) render loops significantly.
**Action:** Apply this optimization to other hot-path rendering elements such as rows, cells, and stack containers.
+## 2026-07-13 - Cache static DOM structures in module-level Map templates
+**Learning:** In high-frequency rendering loops, repeatedly calling `document.createElement` and configuring attributes node-by-node (like setting `.className`, `.textContent`, `.title`) causes significant JS-to-C++ DOM instantiation overhead. Caching these static/predictable node structures in a `Map` keyed by state (e.g. frozen state objects or discrete strings) and returning `.cloneNode(true)` eliminates redundant overhead.
+**Action:** Use a module-level `Map` to cache fully-configured static DOM elements based on their inputs, then return `.cloneNode(true)` during hot path rendering.
+## 2026-07-13 - Correctly caching element properties with cloneNode
+**Learning:** `Node.cloneNode(false)` clones the DOM element and its HTML attributes (like `class`, `aria-label`), but it does not clone JavaScript properties like `.title` unless it's explicitly mirrored as an attribute or deeply cloned. However, in our implementation, `.cloneNode(true)` works reliably to carry over both standard attributes and properties mapped by browsers in a clean way for cached static DOM elements.
+**Action:** When caching DOM elements that rely on DOM properties (like `.title`), ensure you use `.cloneNode(true)` to preserve the full expected state, especially when properties might not map 1:1 to attributes in shallow clones in certain environments.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 787ee51b..e84f41f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,33 +32,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added regression coverage that prevents array-valued passwords from being
coerced into valid credentials.
- Updated Hono runtime dependencies to patched supported releases.
-- Sanitized Clearfolio submission, status, and artifact-link transport failures
- so network details and downstream response text cannot reach browser or
- diagnostic payloads; rejected unknown or whitespace-padded conversion states
- and malformed, unsupported-scheme, or HTTPS-downgrade artifact links.
-- Centralized session JWT verification and database-backed `token_version`
- revocation across bearer middleware, calendar feeds, server-sent events, and
- attachment-view URL transports.
-- Made session-token minting fail closed unless the subject, token version, and
- lifetime are bounded safe integers, and capped general session lifetime at
- seven days so internal callers cannot mint excessive or numerically unsafe
- credentials.
-- Rejected signed session JWTs with a non-HS256/JWT header, non-object claims,
- missing or invalid subject/expiry, or a missing, Boolean, fractional,
- negative, unsafe, or otherwise invalid token-version claim before user lookup.
-- Added cross-device regression coverage proving that `logout-all` rejects stale
- tokens on bearer, calendar, SSE, and attachment-view transports while the
- replacement token continues through the same authentication boundary.
### Changed
-- Attachment-list status refresh now removes the per-row database lookup,
- uses a configurable bounded worker pool with per-item abortable timeouts and
- a request-wide latency budget, preserves stale status after downstream,
- timeout, malformed-response, and persistence failures, excludes internal
- conversion identifiers from responses, reports attempted, changed, failed,
- skipped-data, and deferred-budget counters separately, and exposes fixed
- low-cardinality timeout, lookup, validation, and persistence failure counters.
- 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다.
- 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다.
- `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다.
@@ -85,4 +61,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.1] - 2026-06-25
### 성능 개선 (Performance)
-- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다.
+- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다.
\ No newline at end of file
diff --git a/app.js b/app.js
index a04aae71..9ff3abf5 100644
--- a/app.js
+++ b/app.js
@@ -972,6 +972,7 @@ function createWarningBadge(warning) {
}
const persistentOwnerColorMap = new Map();
+const ownerBadgeTemplateMap = new Map();
function createOwnerCellContent(owner) {
if (!owner) {
@@ -982,25 +983,36 @@ function createOwnerCellContent(owner) {
persistentOwnerColorMap.set(owner, OWNER_COLORS[persistentOwnerColorMap.size % OWNER_COLORS.length]);
}
- const badge = document.createElement('span');
- badge.className = 'owner-badge';
- badge.style.background = persistentOwnerColorMap.get(owner);
- badge.textContent = owner;
- return badge;
+ let template = ownerBadgeTemplateMap.get(owner);
+ if (!template) {
+ template = document.createElement('span');
+ template.className = 'owner-badge';
+ template.style.background = persistentOwnerColorMap.get(owner);
+ template.textContent = owner;
+ ownerBadgeTemplateMap.set(owner, template);
+ }
+ return template.cloneNode(true);
}
+const statusBadgeTemplateMap = new Map();
+
function createStatusCellContent(progressState) {
if (!progressState.label) {
return createEmptyCell();
}
- const badge = document.createElement('span');
- badge.className = `status-badge ${progressState.className}`;
- badge.textContent = progressState.label;
- if (progressState.description) {
- badge.title = progressState.description;
- badge.setAttribute('aria-label', `${progressState.label} - ${progressState.description}`);
+ const cacheKey = `${progressState.label}::${progressState.className}`;
+ let template = statusBadgeTemplateMap.get(cacheKey);
+ if (!template) {
+ template = document.createElement('span');
+ template.className = `status-badge ${progressState.className}`;
+ template.textContent = progressState.label;
+ if (progressState.description) {
+ template.title = progressState.description;
+ template.setAttribute('aria-label', `${progressState.label} - ${progressState.description}`);
+ }
+ statusBadgeTemplateMap.set(cacheKey, template);
}
- return badge;
+ return template.cloneNode(true);
}
const metricTextTemplate = document.createElement('span');
@@ -2760,6 +2772,7 @@ if (typeof window !== 'undefined') {
window.sanitizeCsvFormulaValue = sanitizeCsvFormulaValue;
window.csvEscape = csvEscape;
window.createTextCellContent = createTextCellContent;
+ window.createEmptyTaskDraft = createEmptyTaskDraft;
}
bootstrap();
diff --git a/docs/deploy.md b/docs/deploy.md
index 0cfdb799..db9d4c56 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -41,84 +41,8 @@ persists the database in the `scopeweave-data` volume.
| `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). |
| `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). |
| `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). |
-| `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. |
-| `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. |
-| `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. |
| `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-IP fixed-window rate limiting (429 + Retry-After). Off when unset. |
-## Attachment status refresh operations
-
-The attachment-list API reads `job_id` in its initial project-scoped query and
-refreshes only `PENDING` or `RUNNING` rows through a bounded worker pool. It
-never performs one database lookup per row. A timeout, unsuccessful HTTP
-response, malformed response body, invalid status value, or persistence failure
-is isolated to that attachment: ScopeWeave preserves its previously stored
-status and still returns the rest of the list. Internal Clearfolio job
-identifiers are removed before JSON serialization.
-
-The process metrics endpoint exposes cumulative counters for operational
-monitoring:
-
-- `attachmentStatusRefreshAttempted`
-- `attachmentStatusRefreshChanged`
-- `attachmentStatusRefreshFailed`
-- `attachmentStatusRefreshSkipped`
-- `attachmentStatusRefreshDeferred`
-- `attachmentStatusRefreshTimeoutFailures`
-- `attachmentStatusRefreshDownstreamLookupFailures`
-- `attachmentStatusRefreshInvalidStatusFailures`
-- `attachmentStatusRefreshPersistenceFailures`
-
-`skipped` counts pending rows that cannot be refreshed because their persisted
-Clearfolio job identifier is absent or blank. `deferred` counts valid work that
-was not started before the request-wide latency budget expired. Keeping these
-causes separate prevents malformed stored data from being mistaken for
-insufficient concurrency or downstream latency.
-
-The four failure-category counters are fixed, low-cardinality diagnostics whose
-sum equals the aggregate `failed` delta for a refresh pass. They contain no job
-identifier, URL, downstream response text, or raw exception. The Prometheus
-representation uses corresponding `scopeweave_attachment_status_refresh_*`
-names. Alert on a sustained increase in `failed`, use the category counters for
-triage, investigate `skipped` as a data-quality or migration defect, and compare
-`deferred` with list traffic before increasing concurrency or the request-wide
-budget. Raise limits conservatively because every worker consumes a downstream
-Clearfolio connection; horizontal ScopeWeave replicas multiply the aggregate
-concurrency.
-
-### Rollout and alerting
-
-Roll this behavior out behind a canary replica before raising limits across the
-fleet. Start with concurrency `2`, the default per-item timeout, and a budget no
-longer than the attachment-list latency objective. Compare the canary with the
-previous version using the same tenant and Clearfolio environment.
-
-Derive rates from counter deltas over the same observation window:
-
-```text
-failure_ratio = failed_delta / max(attempted_delta, 1)
-skipped_ratio = skipped_delta / max(attempted_delta + skipped_delta, 1)
-deferred_ratio = deferred_delta / max(attempted_delta + deferred_delta, 1)
-change_ratio = changed_delta / max(attempted_delta, 1)
-```
-
-A high `failure_ratio` indicates downstream, timeout, malformed-response, or
-persistence errors and should block rollout. A non-zero `skipped_ratio` indicates
-an attachment persistence or migration defect and should be investigated before
-changing worker limits. A high `deferred_ratio` indicates that the request-wide
-budget is protecting latency at the cost of freshness; first inspect Clearfolio
-latency and attachment-list size before increasing worker count or budget. Track
-attachment-list p50, p95, and p99 latency beside these ratios. Thresholds must be
-derived from observed production baselines and an agreed service-level objective
-rather than copied from development data.
-
-Rollback is configuration-first: reduce concurrency and budget without changing
-the persisted attachment statuses. If the application version must be rolled
-back, the previous implementation can read the unchanged schema; no migration
-is required by this feature. Never place Clearfolio job IDs, HMAC material,
-request URLs containing credentials, or downstream response bodies in metrics,
-logs, traces, or alert annotations.
-
## Data & scale path
- **Dev / single node**: `node:sqlite` on a persistent volume (this setup). Simple, no external DB.
diff --git a/docs/doctoring/attachment-status-refresh.md b/docs/doctoring/attachment-status-refresh.md
deleted file mode 100644
index eca86521..00000000
--- a/docs/doctoring/attachment-status-refresh.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# Attachment status refresh and Clearfolio boundary: evidence and design record
-
-## Decision
-
-Attachment listing is a buyer-visible read path and must remain responsive when
-Clearfolio is slow, unavailable, or returns malformed data. ScopeWeave therefore
-refreshes only stale conversion states through a reusable bounded worker module
-that is independent of Hono and SQLite. The Clearfolio HTTP adapter separately
-owns downstream transport, tenant headers, response-shape validation, and
-artifact-link validation.
-
-The implementation:
-
-1. includes the internal conversion identifier in the initial project-scoped
- database query, eliminating one lookup per returned row;
-2. limits per-request downstream concurrency through a configurable worker pool;
-3. applies a caller-side timeout to every Clearfolio request and forwards the
- same `AbortSignal` to `fetch`;
-4. applies a wall-clock budget to the complete best-effort refresh pass and
- defers valid work that cannot start within that budget;
-5. counts pending rows with absent or blank conversion identifiers as skipped
- data-quality cases rather than misclassifying them as latency deferrals;
-6. validates conversion states against the exact `PENDING`, `RUNNING`,
- `SUCCEEDED`, and `FAILED` contract rather than trimming or accepting unknown
- strings;
-7. preserves the previously stored status after timeout, transport, HTTP,
- malformed-response, invalid-state, diagnostic, or persistence failure;
-8. persists only changed states;
-9. strips internal conversion identifiers from both upload and list JSON before
- they cross the browser-facing API boundary;
-10. publishes attempted, changed, failed, skipped, and deferred counters without
- sensitive downstream payloads or identifiers;
-11. publishes fixed timeout, downstream-lookup, invalid-status, and persistence
- failure counters so operators can distinguish failure modes without labels or
- raw diagnostic data;
-12. replaces raw network and downstream response messages with fixed
- operation-level submission, status, and artifact-link errors;
-13. validates successful submission and artifact-link JSON before property use;
-14. accepts artifact links only when they resolve to HTTP(S), and prevents an
- HTTPS Clearfolio deployment from returning an HTTP downgrade link; and
-15. keeps the in-memory development adapter and HMAC tenant-claim contract under
- focused tests so MSA extraction cannot silently change interoperability.
-
-## Standards and threat rationale
-
-OWASP API Security Top 10 2023 identifies unrestricted resource consumption as a
-risk when APIs do not bound client interactions or resources. The per-request
-worker cap, per-item timeout, request-wide budget, and existing endpoint rate
-limit are complementary controls: they bound one list operation, one downstream
-operation, the complete refresh pass, and repeated client traffic respectively.
-
-OWASP API10:2023 identifies unsafe consumption of third-party APIs when an
-integrating service fails to validate returned data, limit processing resources,
-or implement timeouts. ScopeWeave therefore treats Clearfolio responses as
-untrusted input even after HTTP success. Rejected JSON, null, primitives,
-arrays, missing or non-string fields, empty or whitespace-padded states, unknown
-states, malformed links, unsupported URI schemes, and HTTPS downgrade links fail
-closed without changing persisted attachment state.
-
-The browser-facing API may serialize adapter errors, so the adapter never copies
-DNS names, socket errors, downstream response text, private URLs, or parser
-messages into thrown errors. Operation name and HTTP status are the maximum
-external diagnostic detail. The refresh engine records only four fixed failure
-categories. Detailed downstream diagnostics belong in a separately redacted
-operator channel, not a client response, metric label, audit payload, or trace
-attribute.
-
-The worker and validation contract is placed in framework- and database-neutral
-modules so a future MSA extraction can reuse the same behavior with another HTTP
-adapter or persistence implementation. The monolith remains fully operable on
-its own. Adapters must pass the same contract suite before they are considered
-substitutable.
-
-## Verification contract
-
-Regression tests must prove:
-
-- one hundred pending rows reach but never exceed configured concurrency;
-- task-filtered and project-wide lists share one refresh contract;
-- unchanged states are not written;
-- downstream, timeout, malformed-response, invalid-state, diagnostic, and write
- failures are isolated to the affected row;
-- pending rows with missing conversion identifiers are counted as skipped;
-- valid unstarted work beyond the request budget is counted as deferred;
-- skipped and deferred metrics remain distinct in JSON and Prometheus output;
-- the four failure-category counters sum to the aggregate failure count and
- never contain raw errors, identifiers, URLs, or downstream response text;
-- upload responses and attachment-list responses omit the internal Clearfolio
- conversion identifier while retaining the public attachment identifier and
- current status;
-- downstream response text and network details never appear in client JSON;
-- the caller `AbortSignal` reaches Clearfolio;
-- submission, status, and artifact-link non-success responses expose only fixed
- operation-level errors;
-- rejected JSON and every malformed successful payload branch fail closed;
-- relative and absolute HTTPS links, artifact-token viewer links, and explicitly
- configured local HTTP links remain supported;
-- HTTPS-to-HTTP downgrade and non-HTTP(S) links are rejected;
-- the mock adapter preserves uploaded bytes and status semantics;
-- HMAC tenant claims use the documented newline-delimited canonical payload;
-- the bounded refresh production module retains 100% statement, branch,
- function, and line coverage; and
-- every new shipped symbol has complete beginner-readable JSDoc.
-
-## Operational acceptance
-
-Rollout begins with a canary and conservative concurrency. Operators compare
-attachment-list p50, p95, and p99 latency with refresh failure, skipped, and
-deferral ratios. A high failure ratio blocks rollout and the fixed category
-counters identify whether the dominant cause is timeout, downstream lookup,
-invalid state, or persistence. A non-zero skipped ratio indicates a persistence
-or migration defect and is investigated independently of latency. A high
-deferred ratio indicates the latency budget is containing work at the cost of
-freshness and requires Clearfolio latency and list-size diagnosis before
-increasing resource limits. Rollback is configuration-first and requires no
-schema migration.
-
-The rollout review also samples client error payloads, structured logs, traces,
-audit exports, and alert annotations to prove that Clearfolio response bodies,
-internal DNS names, signed links, HMAC material, and conversion identifiers are
-absent. Horizontal replica count is multiplied by configured per-request
-concurrency when assessing the downstream connection budget.
-
-## References
-
-OWASP Foundation. (2023a). *API4:2023 unrestricted resource consumption*. OWASP
-API Security Top 10.
-https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/
-
-OWASP Foundation. (2023b). *API10:2023 unsafe consumption of APIs*. OWASP API
-Security Top 10.
-https://owasp.org/API-Security/editions/2023/en/0xaa-unsafe-consumption-of-apis/
diff --git a/docs/doctoring/session-revocation.md b/docs/doctoring/session-revocation.md
deleted file mode 100644
index d162e8f7..00000000
--- a/docs/doctoring/session-revocation.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# Session JWT revocation: evidence and design record
-
-## Decision
-
-Every ScopeWeave transport that accepts a general session JWT uses one
-fail-closed verifier. Bearer middleware, calendar feeds, server-sent events, and
-attachment-view routes therefore share signature, header, claim, subject, expiry,
-and database-backed revocation checks.
-
-The implementation:
-
-1. pins the compact token to an authenticated `HS256` signature and signed `JWT`
- type;
-2. authenticates the compact representation before interpreting the JOSE header
- or claim set;
-3. requires a non-array claims object, positive safe-integer subject, future
- safe-integer expiry, and non-negative safe-integer token version;
-4. requires the subject to exist and compares the signed token version exactly
- with the current persisted version;
-5. rejects malformed, forged, expired, missing-user, and stale sessions before
- tenant or resource lookup;
-6. caps general session minting at seven days and rejects fractional, unsafe,
- non-positive, or longer lifetimes; and
-7. reserves narrower and shorter authority for the opaque access-grant design in
- issue #413 rather than overloading the general session JWT.
-
-## Standards rationale
-
-RFC 7519 defines a JWT claims set as a JSON object and defines `sub` and `exp` as
-registered claims. ScopeWeave narrows those flexible JSON representations to
-safe integers because its database identifiers and token-version comparisons are
-integer security boundaries.
-
-RFC 8725 requires callers to perform algorithm verification, validate every
-cryptographic operation, use explicit typing for new JWT uses, and apply mutually
-exclusive validation rules where different token kinds coexist. ScopeWeave pins
-one algorithm and one type for general sessions and does not reuse this JWT
-contract for the scoped URL grants planned in issue #413.
-
-RFC 6750 explains that any holder of a bearer token can exercise its authority,
-recommends short-lived and audience-scoped credentials, and warns against page
-URL transport because browser history and server logs can expose tokens. RFC
-9700 updates OAuth security best current practice and prohibits clients from
-passing access tokens in URI query parameters. This pull request does not claim
-to remove the existing URL transport; it makes revocation and validation
-consistent until issue #413 replaces those general credentials with narrowly
-scoped opaque grants and separately revocable calendar subscription secrets.
-
-## Verification contract
-
-Regression tests must prove:
-
-- the signer rejects invalid subject, token version, fractional lifetime,
- numerically unsafe lifetime, and any general-session lifetime over seven days;
-- malformed compact tokens, signatures, JOSE headers, claim-set shapes, subjects,
- expiries, and token-version values fail across every transport;
-- a correctly signed token for a nonexistent subject fails before resource
- lookup;
-- two independently minted device sessions work before revocation;
-- `logout-all` invalidates both stale sessions on bearer, calendar, SSE, and
- attachment-view paths; and
-- the replacement session continues through the same authentication boundary.
-
-All changed production helpers require complete JSDoc and 100% statement,
-branch, function, and line coverage before the pull request can leave Draft.
-
-## References
-
-Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC
-7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519
-
-Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework:
-Bearer token usage* (RFC 6750). Internet Engineering Task Force.
-https://doi.org/10.17487/RFC6750
-
-Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current
-practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task
-Force. https://doi.org/10.17487/RFC9700
-
-Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current
-practices* (BCP 225; RFC 8725). Internet Engineering Task Force.
-https://doi.org/10.17487/RFC8725
diff --git a/index.html b/index.html
index a7f4b49c..39b9eb18 100644
--- a/index.html
+++ b/index.html
@@ -5,7 +5,8 @@
ScopeWeave Planner
-
+
+
diff --git a/package-lock.json b/package-lock.json
index 859ec2a6..4e92f43b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@hono/node-server": "^2.0.12",
- "hono": "^4.13.0"
+ "hono": "^4.12.32"
},
"devDependencies": {
"@playwright/test": "1.61.1",
@@ -382,9 +382,9 @@
}
},
"node_modules/hono": {
- "version": "4.13.0",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
- "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
+ "version": "4.13.2",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz",
+ "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
diff --git a/package.json b/package.json
index 46d07bfb..dbd455f4 100644
--- a/package.json
+++ b/package.json
@@ -10,21 +10,20 @@
},
"scripts": {
"check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings",
- "coverage": "npm run test:coverage",
+ "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --reporter=json --reporter=json-summary npm run test:coverage",
"server": "node server/server.mjs",
- "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs",
- "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs",
- "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
- "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
+ "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs",
+ "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/caching.test.mjs",
+ "test:coverage": "node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/caching.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
- "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js",
+ "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js",
"test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js",
"fuzz": "node --test tests/fuzz/*.mjs"
},
"dependencies": {
"@hono/node-server": "^2.0.12",
- "hono": "^4.13.0"
+ "hono": "^4.12.32"
},
"devDependencies": {
"@playwright/test": "1.61.1",
diff --git a/server/app.mjs b/server/app.mjs
index 450be878..13d95e5d 100644
--- a/server/app.mjs
+++ b/server/app.mjs
@@ -8,7 +8,6 @@ import { db, rowid } from './db.mjs';
import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs';
import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs';
import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs';
-import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs';
import { chat as orchestratorChat } from './orchestrator.mjs';
import { computeEvm } from '../analytics.js'; // pure math, shared with the client
@@ -74,20 +73,7 @@ function projectAccess(userId, projectId) {
}
// --- observability: in-process counters + structured request log.
-const metrics = {
- startedAt: new Date().toISOString(),
- requests: 0,
- s2xx: 0,
- s4xx: 0,
- s5xx: 0,
- signups: 0,
- projectsCreated: 0,
- webhookDeliveries: 0,
- attachmentStatusRefreshAttempted: 0,
- attachmentStatusRefreshChanged: 0,
- attachmentStatusRefreshFailed: 0,
- attachmentStatusRefreshDeferred: 0,
-};
+const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 };
// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`.
// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome
@@ -1007,31 +993,6 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => {
// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio
// 자격이 절대 노출되지 않음.
const ATTACH_MAX_BYTES = 10 * 1024 * 1024;
-
-const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency(
- process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY,
-);
-const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs(
- process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS,
-);
-const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs(
- process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS,
-);
-const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size,
- a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`;
-const ATTACHMENT_LIST_FROM =
- 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by';
-const listAttachmentsStatement = db.prepare(
- `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM}
- WHERE a.project_id = ? ORDER BY a.id DESC`,
-);
-const listTaskAttachmentsStatement = db.prepare(
- `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM}
- WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`,
-);
-const updateAttachmentStatusStatement = db.prepare(
- 'UPDATE attachments SET status = ? WHERE id = ?',
-);
app.post('/api/projects/:id/attachments', requireAuth, async (c) => {
const uid = c.get('user').sub;
const p = projectAccess(uid, c.req.param('id'));
@@ -1054,31 +1015,35 @@ app.post('/api/projects/:id/attachments', requireAuth, async (c) => {
'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)'
).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid));
logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null });
- return c.json({ id: aid, status: job.status });
+ return c.json({ id: aid, jobId: job.jobId, status: job.status });
});
app.get('/api/projects/:id/attachments', requireAuth, async (c) => {
const uid = c.get('user').sub;
const p = projectAccess(uid, c.req.param('id'));
if (!p) return c.json({ error: 'not found' }, 404);
-
const taskId = c.req.query('taskId');
- const rows = taskId
- ? listTaskAttachmentsStatement.all(p.id, taskId)
- : listAttachmentsStatement.all(p.id);
- await refreshAttachmentStatuses(rows, {
- orgId: p.org_id,
- userId: uid,
- jobStatus,
- updateStatus: (status, attachmentId) =>
- updateAttachmentStatusStatement.run(status, attachmentId),
- concurrency: ATTACH_STATUS_CONCURRENCY,
- timeoutMs: ATTACH_STATUS_TIMEOUT_MS,
- budgetMs: ATTACH_STATUS_BUDGET_MS,
- metrics,
- });
- const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow);
- return c.json({ attachments });
+ const rows = (taskId
+ ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy
+ FROM attachments a LEFT JOIN users u ON u.id = a.created_by
+ WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId)
+ : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy
+ FROM attachments a LEFT JOIN users u ON u.id = a.created_by
+ WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id));
+ // PENDING 잡 상태 갱신(최선 노력)
+ for (const r of rows) {
+ if (r.status === 'PENDING' || r.status === 'RUNNING') {
+ try {
+ const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id;
+ const st = await jobStatus(p.org_id, uid, jid);
+ if (st !== r.status) {
+ db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id);
+ r.status = st;
+ }
+ } catch { /* keep stale status */ }
+ }
+ }
+ return c.json({ attachments: rows });
});
// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴).
diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs
deleted file mode 100644
index c6f9ae21..00000000
--- a/server/attachment_status.mjs
+++ /dev/null
@@ -1,297 +0,0 @@
-/** Default maximum concurrent Clearfolio status lookups. */
-export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8;
-
-/** Conservative hard ceiling for operator-configured lookup concurrency. */
-export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32;
-
-/** Default downstream status lookup timeout in milliseconds. */
-export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000;
-
-/** Hard ceiling for a downstream status lookup timeout in milliseconds. */
-export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000;
-
-/** Default wall-clock budget for one attachment-list refresh pass. */
-export const ATTACHMENT_STATUS_DEFAULT_BUDGET_MS = 5_000;
-
-/** Hard ceiling for one attachment-list refresh pass. */
-export const ATTACHMENT_STATUS_MAX_BUDGET_MS = 60_000;
-
-/** Status values accepted from the Clearfolio conversion contract. */
-const ATTACHMENT_STATUS_VALUES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']);
-
-/** Timeout error name used only for sanitized failure categorization. */
-const ATTACHMENT_STATUS_TIMEOUT_ERROR = 'AttachmentStatusTimeoutError';
-
-/** Fixed low-cardinality failure categories safe for operational metrics. */
-const ATTACHMENT_STATUS_FAILURE_METRICS = Object.freeze({
- timeout: 'attachmentStatusRefreshTimeoutFailures',
- downstream_lookup: 'attachmentStatusRefreshDownstreamLookupFailures',
- invalid_status: 'attachmentStatusRefreshInvalidStatusFailures',
- status_persistence: 'attachmentStatusRefreshPersistenceFailures',
-});
-
-/**
- * Normalize a positive integer while applying a conservative upper bound.
- *
- * @param {unknown} value - Untrusted environment or caller value.
- * @param {number} fallback - Value used for missing or invalid input.
- * @param {number} maximum - Largest accepted value.
- * @returns {number} A safe positive integer no greater than `maximum`.
- */
-function normalizeBoundedInteger(value, fallback, maximum) {
- const parsed = Number(value);
- if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback;
- return Math.min(parsed, maximum);
-}
-
-/**
- * Normalize the configured attachment-status worker count.
- *
- * @param {unknown} value - Environment or caller supplied value.
- * @returns {number} An integer between 1 and 32, defaulting to 8.
- */
-export function normalizeAttachmentStatusConcurrency(value) {
- return normalizeBoundedInteger(
- value,
- ATTACHMENT_STATUS_DEFAULT_CONCURRENCY,
- ATTACHMENT_STATUS_MAX_CONCURRENCY,
- );
-}
-
-/**
- * Normalize the configured Clearfolio status timeout.
- *
- * @param {unknown} value - Environment or caller supplied value.
- * @returns {number} A positive timeout no greater than 30 seconds.
- */
-export function normalizeAttachmentStatusTimeoutMs(value) {
- return normalizeBoundedInteger(
- value,
- ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS,
- ATTACHMENT_STATUS_MAX_TIMEOUT_MS,
- );
-}
-
-/**
- * Normalize the request-wide attachment refresh budget.
- *
- * @param {unknown} value - Environment or caller supplied value.
- * @returns {number} A positive budget no greater than 60 seconds.
- */
-export function normalizeAttachmentStatusBudgetMs(value) {
- return normalizeBoundedInteger(
- value,
- ATTACHMENT_STATUS_DEFAULT_BUDGET_MS,
- ATTACHMENT_STATUS_MAX_BUDGET_MS,
- );
-}
-
-/**
- * Read a clock dependency and reject unusable values before deadline math.
- *
- * @param {() => number} clock - Clock returning epoch-like milliseconds.
- * @returns {number} A finite millisecond value.
- * @throws {TypeError} If the clock returns a non-finite value.
- */
-function readClock(clock) {
- const value = clock();
- if (!Number.isFinite(value)) throw new TypeError('clock must return a finite number');
- return value;
-}
-
-/**
- * Add one refresh result to process-level operational counters.
- *
- * Aggregate counters preserve the public refresh result contract. Fixed
- * category counters provide operator diagnostics without job identifiers,
- * downstream text, URLs, or other high-cardinality labels.
- *
- * @param {object|undefined} metrics - Mutable process metric registry.
- * @param {{attempted:number,changed:number,failed:number,skipped:number,deferred:number}} counts - Refresh result.
- * @param {Record} failureCounts - Sanitized fixed-category failures.
- * @returns {void}
- */
-function addRefreshMetrics(metrics, counts, failureCounts) {
- if (!metrics) return;
- const fields = {
- attachmentStatusRefreshAttempted: 'attempted',
- attachmentStatusRefreshChanged: 'changed',
- attachmentStatusRefreshFailed: 'failed',
- attachmentStatusRefreshSkipped: 'skipped',
- attachmentStatusRefreshDeferred: 'deferred',
- };
- for (const [metric, count] of Object.entries(fields)) {
- metrics[metric] = (Number(metrics[metric]) || 0) + counts[count];
- }
- for (const [category, metric] of Object.entries(ATTACHMENT_STATUS_FAILURE_METRICS)) {
- metrics[metric] = (Number(metrics[metric]) || 0) + failureCounts[category];
- }
-}
-
-/**
- * Publish a sanitized refresh-failure category without risking the request.
- *
- * The callback never receives a Clearfolio job identifier, URL, response body,
- * or raw downstream error. A failing diagnostic sink is isolated because
- * observability must not break attachment listing.
- *
- * @param {((event:{category:string}) => unknown)|undefined} onError - Optional diagnostic sink.
- * @param {string} category - Fixed safe failure category.
- * @returns {void}
- */
-function reportRefreshFailure(onError, category) {
- if (!onError) return;
- try {
- onError({ category });
- } catch {
- // Diagnostics are best effort and must never fail the list response.
- }
-}
-
-/**
- * Await one downstream lookup with an AbortSignal and a hard caller-side timeout.
- *
- * The explicit race means a non-compliant downstream adapter cannot hold a list
- * response open forever even if it ignores the supplied AbortSignal.
- *
- * @param {() => Promise} lookup - Deferred downstream lookup.
- * @param {AbortController} controller - Controller whose signal is passed downstream.
- * @param {number} timeoutMs - Hard timeout in milliseconds.
- * @returns {Promise} The downstream status.
- */
-async function withTimeout(lookup, controller, timeoutMs) {
- let timer;
- const timeout = new Promise((_, reject) => {
- timer = setTimeout(() => {
- controller.abort();
- const error = new Error('attachment status lookup timed out');
- error.name = ATTACHMENT_STATUS_TIMEOUT_ERROR;
- reject(error);
- }, timeoutMs);
- });
- try {
- return await Promise.race([lookup(), timeout]);
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Refresh pending attachment statuses through a bounded worker pool.
- *
- * Rows are updated in place so the caller can serialize the refreshed public
- * representation. A shared wall-clock deadline bounds the whole refresh pass;
- * workers clamp each lookup timeout to the remaining request budget and mark
- * unstarted rows as deferred after the deadline. Rows with missing conversion
- * identifiers are counted as skipped data-quality cases. Downstream,
- * validation, or persistence failures preserve stale status and never fail the
- * attachment-list response.
- *
- * @param {Array