@mriekki/project-forge 4.26.1 → 4.27.1
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/assets/authentication/distribution.json +90 -0
- package/assets/components.json +17 -3
- package/assets/core/distribution.json +12 -0
- package/assets/deployment/distribution.json +292 -68
- package/assets/generated/ASSEMBLY.json +150 -4
- package/assets/generated/authentication/docs/AUTH_RECOVERY_RETENTION.md +101 -0
- package/assets/generated/authentication/docs/HOUSEHOLD_INFRASTRUCTURE_ADOPTION.md +55 -0
- package/assets/generated/authentication/docs/HOUSEHOLD_REGISTRY.md +246 -0
- package/assets/generated/authentication/docs/PROJECT_AUTH_REFERENCE.md +878 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/Program.cs +40 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj +13 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/auth.js +65 -0
- package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/index.html +44 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/AdmissionPolicy.cs +167 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/AuthenticationDiagnostics.cs +159 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/ProjectForge.Auth.csproj +9 -0
- package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs +64 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectAuthStore.cs +471 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj +15 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForgeAuthRuntimeExtensions.cs +472 -0
- package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ResendProjectInvitationDelivery.cs +105 -0
- package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj +11 -0
- package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs +95 -0
- package/assets/generated/authentication/tools/household_connect.py +145 -0
- package/assets/generated/authentication/tools/project_auth_recovery.py +716 -0
- package/assets/generated/core/core/process/ENGINEERING_BACKLOG.md +150 -0
- package/assets/generated/core/core/skills/manage-backlog/SKILL.md +115 -0
- package/assets/generated/core/core/skills/review-user-feedback/SKILL.md +50 -0
- package/assets/generated/deployment/docs/RECOVERY_READINESS.md +103 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_capture.py +164 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_guard.py +225 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_lifecycle.py +455 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_retention.py +220 -0
- package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_schedule.py +489 -0
- package/package.json +1 -1
- package/src/cli.mjs +32 -3
- package/src/engine.mjs +87 -5
package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Linq;
|
|
3
|
+
using System.Net;
|
|
4
|
+
using Microsoft.AspNetCore.Http;
|
|
5
|
+
|
|
6
|
+
namespace ProjectForge.Transport;
|
|
7
|
+
|
|
8
|
+
/// <summary>
|
|
9
|
+
/// Exact Caddy-to-application ingress boundary, modeled on the proven PWA
|
|
10
|
+
/// ProductionOriginTransport contract. Do not use alongside generic forwarded
|
|
11
|
+
/// header processing: the raw physical peer and single forwarded headers
|
|
12
|
+
/// must be checked BEFORE any middleware rewrites them.
|
|
13
|
+
/// </summary>
|
|
14
|
+
public static class StrictPublicOriginTransport
|
|
15
|
+
{
|
|
16
|
+
public static bool TryApply(HttpContext context, IPAddress reviewedCaddy, Uri origin)
|
|
17
|
+
{
|
|
18
|
+
var request = context.Request;
|
|
19
|
+
var physicalPeer = Normalize(context.Connection.RemoteIpAddress);
|
|
20
|
+
if (physicalPeer is null || !physicalPeer.Equals(Normalize(reviewedCaddy)) ||
|
|
21
|
+
origin.Scheme != Uri.UriSchemeHttps ||
|
|
22
|
+
request.Host.Value != origin.Authority ||
|
|
23
|
+
!ExactHeader(request, "X-Forwarded-Host", origin.Authority) ||
|
|
24
|
+
!ExactHeader(request, "X-Forwarded-Proto", "https") ||
|
|
25
|
+
!ReadIp(request, "X-Forwarded-For", out var client) ||
|
|
26
|
+
!ReadIp(request, "X-Real-IP", out var real) ||
|
|
27
|
+
!client.Equals(real) ||
|
|
28
|
+
request.Headers.Keys.Any(k => IsForwardingHeader(k) && !IsCanonicalHeader(k)))
|
|
29
|
+
return false;
|
|
30
|
+
|
|
31
|
+
request.Scheme = Uri.UriSchemeHttps;
|
|
32
|
+
request.Host = origin.IsDefaultPort
|
|
33
|
+
? new HostString(origin.Host)
|
|
34
|
+
: new HostString(origin.Host, origin.Port);
|
|
35
|
+
context.Connection.RemoteIpAddress = client;
|
|
36
|
+
StripForwardingHeaders(request);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// <summary>Diagnostic endpoints must be GET, loopback-only, and free of proxy claims.</summary>
|
|
41
|
+
public static bool AllowLocalDiagnostic(HttpContext context)
|
|
42
|
+
{
|
|
43
|
+
if (!HttpMethods.IsGet(context.Request.Method) ||
|
|
44
|
+
context.Connection.RemoteIpAddress is not IPAddress address ||
|
|
45
|
+
!IPAddress.IsLoopback(address))
|
|
46
|
+
return false;
|
|
47
|
+
StripForwardingHeaders(context.Request);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private static bool ExactHeader(HttpRequest request, string name, string expected)
|
|
52
|
+
{
|
|
53
|
+
var value = request.Headers[name];
|
|
54
|
+
return value.Count == 1 &&
|
|
55
|
+
string.Equals(value[0], expected, StringComparison.Ordinal);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private static bool ReadIp(HttpRequest request, string header, out IPAddress ip)
|
|
59
|
+
{
|
|
60
|
+
ip = IPAddress.None;
|
|
61
|
+
var value = request.Headers[header];
|
|
62
|
+
if (value.Count != 1 || string.IsNullOrEmpty(value[0]))
|
|
63
|
+
return false;
|
|
64
|
+
var raw = value[0]!;
|
|
65
|
+
if (raw.Any(char.IsWhiteSpace) || raw.Contains(',') ||
|
|
66
|
+
!IPAddress.TryParse(raw, out var parsed) ||
|
|
67
|
+
parsed.Equals(IPAddress.Any) || parsed.Equals(IPAddress.IPv6Any))
|
|
68
|
+
return false;
|
|
69
|
+
ip = Normalize(parsed)!;
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private static IPAddress? Normalize(IPAddress? ip) =>
|
|
74
|
+
ip?.IsIPv4MappedToIPv6 == true ? ip.MapToIPv4() : ip;
|
|
75
|
+
|
|
76
|
+
private static bool IsCanonicalHeader(string name) =>
|
|
77
|
+
name.Equals("X-Forwarded-For", StringComparison.OrdinalIgnoreCase) ||
|
|
78
|
+
name.Equals("X-Forwarded-Host", StringComparison.OrdinalIgnoreCase) ||
|
|
79
|
+
name.Equals("X-Forwarded-Proto", StringComparison.OrdinalIgnoreCase) ||
|
|
80
|
+
name.Equals("X-Real-IP", StringComparison.OrdinalIgnoreCase);
|
|
81
|
+
|
|
82
|
+
private static bool IsForwardingHeader(string name) =>
|
|
83
|
+
name.StartsWith("X-Forwarded-", StringComparison.OrdinalIgnoreCase) ||
|
|
84
|
+
name.Equals("X-Real-IP", StringComparison.OrdinalIgnoreCase) ||
|
|
85
|
+
name.Equals("Forwarded", StringComparison.OrdinalIgnoreCase) ||
|
|
86
|
+
name.Equals("True-Client-IP", StringComparison.OrdinalIgnoreCase) ||
|
|
87
|
+
name.Equals("CF-Connecting-IP", StringComparison.OrdinalIgnoreCase) ||
|
|
88
|
+
name.Equals("CF-Visitor", StringComparison.OrdinalIgnoreCase);
|
|
89
|
+
|
|
90
|
+
private static void StripForwardingHeaders(HttpRequest request)
|
|
91
|
+
{
|
|
92
|
+
foreach (var header in request.Headers.Keys.Where(IsForwardingHeader).ToArray())
|
|
93
|
+
request.Headers.Remove(header);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Explicit, non-secret cross-workstation Household Registry locator.
|
|
3
|
+
|
|
4
|
+
Stores URL + stable Registry instance fingerprint in a protected local file.
|
|
5
|
+
This does not authenticate a Developer, read the Household database, install
|
|
6
|
+
OAuth clients or grant cross-Project access. Each Developer still signs in with
|
|
7
|
+
Google and is independently invited by the household administrator.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
HEX32 = re.compile(r"^[0-9a-f]{32}$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConnectionError(ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
29
|
+
def redirect_request(self, request, fp, code, msg, headers, newurl):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_origin(origin: str) -> str:
|
|
34
|
+
if not isinstance(origin, str) or len(origin) > 254:
|
|
35
|
+
raise ConnectionError("Registry locator must be an exact HTTPS origin.")
|
|
36
|
+
parsed = urlparse(origin)
|
|
37
|
+
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or \
|
|
38
|
+
parsed.query or parsed.fragment or parsed.path not in ("", "/"):
|
|
39
|
+
raise ConnectionError("Registry locator must be an exact HTTPS origin.")
|
|
40
|
+
if parsed.hostname.lower() == "localhost" or parsed.hostname.startswith("127."):
|
|
41
|
+
raise ConnectionError("Registry locator must use a verified public TLS name.")
|
|
42
|
+
return origin.rstrip("/")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def probe(origin: str) -> dict:
|
|
46
|
+
origin = validate_origin(origin)
|
|
47
|
+
url = origin + "/.well-known/project-forge-household"
|
|
48
|
+
opener = urllib.request.build_opener(NoRedirect())
|
|
49
|
+
req = urllib.request.Request(url, headers={"Accept":"application/json"}, method="GET")
|
|
50
|
+
try:
|
|
51
|
+
with opener.open(req, timeout=10) as response:
|
|
52
|
+
if response.status != 200 or response.url != url:
|
|
53
|
+
raise ConnectionError("Registry discovery did not return an exact trusted origin.")
|
|
54
|
+
raw = response.read(8193)
|
|
55
|
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error:
|
|
56
|
+
raise ConnectionError("Registry discovery is unavailable; do not assume existing Projects are offline.") from error
|
|
57
|
+
if len(raw) > 8192:
|
|
58
|
+
raise ConnectionError("Registry discovery response exceeds safe bounds.")
|
|
59
|
+
try:
|
|
60
|
+
obj = json.loads(raw)
|
|
61
|
+
except (UnicodeError, ValueError) as error:
|
|
62
|
+
raise ConnectionError("Registry discovery is not valid JSON.") from error
|
|
63
|
+
if not isinstance(obj, dict) or obj.get("schemaVersion") != 1 or \
|
|
64
|
+
obj.get("service") != "project-forge-household-registry" or \
|
|
65
|
+
obj.get("authentication") != "google-oidc" or \
|
|
66
|
+
not isinstance(obj.get("instanceId"), str) or not HEX32.fullmatch(obj["instanceId"]):
|
|
67
|
+
raise ConnectionError("Registry discovery does not match the supported contract.")
|
|
68
|
+
needed = {"member-directory", "resource-inventory", "project-registry"}
|
|
69
|
+
if not isinstance(obj.get("capabilities"), list) or \
|
|
70
|
+
not needed.issubset(set(x for x in obj["capabilities"] if isinstance(x, str))):
|
|
71
|
+
raise ConnectionError("Registry discovery lacks required capabilities.")
|
|
72
|
+
return {"schemaVersion":1, "kind":"project-forge-household-connection",
|
|
73
|
+
"registryOrigin":origin, "instanceId":obj["instanceId"]}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def default_path() -> Path:
|
|
77
|
+
if os.name == "nt":
|
|
78
|
+
base = Path(os.environ.get("APPDATA", str(Path.home()))) / "ProjectForge"
|
|
79
|
+
else:
|
|
80
|
+
base = Path.home() / ".config" / "project-forge"
|
|
81
|
+
return base / "household.json"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def connect(origin: str, location: Path) -> dict:
|
|
85
|
+
connection = probe(origin)
|
|
86
|
+
if location.is_file():
|
|
87
|
+
try:
|
|
88
|
+
saved = json.loads(location.read_text(encoding="utf-8"))
|
|
89
|
+
except (OSError, ValueError) as error:
|
|
90
|
+
raise ConnectionError("Existing connection is damaged; manual review required.") from error
|
|
91
|
+
if saved != connection:
|
|
92
|
+
raise ConnectionError("Registry identity changed; manual review required before reconnecting.")
|
|
93
|
+
return connection
|
|
94
|
+
if not location.parent.is_dir():
|
|
95
|
+
raise ConnectionError("Create and protect the local connection directory before setup.")
|
|
96
|
+
try:
|
|
97
|
+
fd = os.open(location, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
98
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
99
|
+
json.dump(connection, stream, sort_keys=True, indent=2)
|
|
100
|
+
stream.write("\n")
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise ConnectionError("Unable to create a non-overwriting connection locator.") from error
|
|
103
|
+
return connection
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def doctor(location: Path) -> dict:
|
|
107
|
+
if not location.is_file() or location.stat().st_size > 4096:
|
|
108
|
+
raise ConnectionError("Household connection locator is missing or invalid.")
|
|
109
|
+
try:
|
|
110
|
+
stored = json.loads(location.read_text(encoding="utf-8"))
|
|
111
|
+
except (ValueError, OSError) as error:
|
|
112
|
+
raise ConnectionError("Household connection locator cannot be read.") from error
|
|
113
|
+
if not isinstance(stored, dict) or set(stored) != {
|
|
114
|
+
"schemaVersion", "kind", "registryOrigin", "instanceId"}:
|
|
115
|
+
raise ConnectionError("Household connection schema is not recognized.")
|
|
116
|
+
found = probe(stored["registryOrigin"])
|
|
117
|
+
if found != stored:
|
|
118
|
+
raise ConnectionError("Registry identity changed; block automatic adoption.")
|
|
119
|
+
return found
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
parser = argparse.ArgumentParser(description="Connect a workstation to an existing Project Forge Household Registry")
|
|
124
|
+
parser.add_argument("command", choices=["connect", "doctor"])
|
|
125
|
+
parser.add_argument("--registry-origin", help="Explicit administrator-confirmed HTTPS Registry URL")
|
|
126
|
+
parser.add_argument("--connection-file", type=Path, default=default_path())
|
|
127
|
+
args = parser.parse_args(argv)
|
|
128
|
+
try:
|
|
129
|
+
if args.command == "connect":
|
|
130
|
+
if not args.registry_origin:
|
|
131
|
+
raise ConnectionError("Explicit Registry origin is required.")
|
|
132
|
+
state = connect(args.registry_origin, args.connection_file)
|
|
133
|
+
else:
|
|
134
|
+
state = doctor(args.connection_file)
|
|
135
|
+
except (ConnectionError, OSError) as error:
|
|
136
|
+
print("BLOCKED: " + str(error), file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
print("Registry connection verified: " + state["registryOrigin"])
|
|
139
|
+
print("Instance fingerprint: " + state["instanceId"])
|
|
140
|
+
print("Google sign-in and an explicit household invitation are still required.")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
raise SystemExit(main())
|