Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7df876c4a | ||
|
|
4a2aa81725 | ||
|
|
3c43f6181e | ||
|
|
ed30eb4c36 | ||
|
|
ec5cf8f9f9 | ||
|
|
4ddc41632b | ||
|
|
2e9a92996e | ||
|
|
ce1e12417b | ||
|
|
b9028e8f7a | ||
|
|
3867467f24 | ||
|
|
a71af41d9e |
@@ -51,6 +51,40 @@ jobs:
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
# The iOS app's shell: the interface built the way the app carries it, and
|
||||
# copied into the Xcode project. No Xcode here — that build happens on a Mac —
|
||||
# but a config the CLI cannot read, a webDir that moved, or an app build that
|
||||
# stopped compiling all fail at this step rather than on the day somebody sits
|
||||
# down to ship.
|
||||
app:
|
||||
name: iOS app shell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
(cd web && npm ci)
|
||||
(cd app && npm ci)
|
||||
|
||||
- name: Build and sync
|
||||
working-directory: app
|
||||
run: npm run sync
|
||||
|
||||
# The app build must not register the service worker — WKWebView has none
|
||||
# for the app's own scheme. The browser build registers it from index.html.
|
||||
- name: No service worker in the app build
|
||||
run: |
|
||||
if grep -qF 'serviceWorker.register' web/build/index.html; then
|
||||
echo "The app build registers a service worker in index.html."
|
||||
exit 1
|
||||
fi
|
||||
test -f app/ios/App/App/public/index.html
|
||||
echo "App build in the Xcode project: $(find app/ios/App/App/public -type f | wc -l) files."
|
||||
|
||||
spec:
|
||||
name: OpenAPI is valid
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -251,6 +251,7 @@ to put one that the operator would ever see.
|
||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. |
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"appId": "dk.nolimit.verdande",
|
||||
"appName": "Verdande",
|
||||
"webDir": "../web/build",
|
||||
"ios": {
|
||||
"contentInset": "never",
|
||||
"scheme": "Verdande"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
App/build
|
||||
App/Pods
|
||||
App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
@@ -0,0 +1,380 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
504EC3011FED79650016851F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
504EC2FB1FED79650016851F = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
|
||||
504EC3061FED79650016851F /* App */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3061FED79650016851F /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9582B6822FE993A50072D4E8 /* SceneDelegate.swift */,
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
504EC30B1FED79650016851F /* Main.storyboard */,
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */,
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
|
||||
504EC3131FED79650016851F /* Info.plist */,
|
||||
2FAD9762203C412B000D30F8 /* config.xml */,
|
||||
50B271D01FEDC1A000F3C39B /* public */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildPhases = (
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
packageProductDependencies = (
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
504EC2FC1FED79650016851F /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 0920;
|
||||
LastUpgradeCheck = 0920;
|
||||
TargetAttributes = {
|
||||
504EC3031FED79650016851F = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
compatibilityVersion = "Xcode 8.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
504EC3021FED79650016851F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */,
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
|
||||
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
504EC3001FED79650016851F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC30C1FED79650016851F /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
504EC3111FED79650016851F /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
504EC3141FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3151FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dk.nolimit.verdande;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3141FED79650016851F /* Debug */,
|
||||
504EC3151FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
504EC3181FED79650016851F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = "CapApp-SPM";
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
|
||||
productName = "CapApp-SPM";
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 504EC2FC1FED79650016851F /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,44 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
let config = UISceneConfiguration(name: "Default Configuration",
|
||||
sessionRole: connectingSceneSession.role)
|
||||
config.delegateClass = SceneDelegate.self
|
||||
return config
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
</imageView>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Splash" width="1366" height="1366"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CAPACITOR_DEBUG</key>
|
||||
<string>$(CAPACITOR_DEBUG)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Verdande</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- A self-hosted Verdande on a home network may have no certificate:
|
||||
http://192.168.1.20:8080 or http://verdande.local. This allows plain
|
||||
http to local addresses only; anything on the internet must still be
|
||||
https. -->
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,24 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window?.rootViewController = CAPBridgeViewController()
|
||||
window?.makeKeyAndVisible()
|
||||
|
||||
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -0,0 +1,25 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
|
||||
let package = Package(
|
||||
name: "CapApp-SPM",
|
||||
platforms: [.iOS(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "CapApp-SPM",
|
||||
targets: ["CapApp-SPM"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.3")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "CapApp-SPM",
|
||||
dependencies: [
|
||||
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# CapApp-SPM
|
||||
|
||||
This package is used to host SPM dependencies for your Capacitor project
|
||||
|
||||
Do not modify the contents of it or there may be unintended consequences.
|
||||
@@ -0,0 +1 @@
|
||||
public let isCapacitorApp = true
|
||||
@@ -0,0 +1 @@
|
||||
CAPACITOR_DEBUG = true
|
||||
Generated
+1239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "verdande-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "The iOS app: the web interface bundled into a native shell.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "cd ../web && VERDANDE_TARGET=app npm run build",
|
||||
"sync": "npm run build && cap sync ios",
|
||||
"open": "cap open ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^8.5.3",
|
||||
"@capacitor/ios": "^8.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,14 @@ straight off the internet is keyed on the address it really came from.
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
|
||||
| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. |
|
||||
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
|
||||
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
|
||||
|
||||
Any Go duration: `720h`, `30m`, `45s`.
|
||||
|
||||
`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it.
|
||||
|
||||
## Mail
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# The iOS app
|
||||
|
||||
The iOS app is the web interface bundled into a native shell with
|
||||
[Capacitor](https://capacitorjs.com). It is not a second interface: the same
|
||||
Svelte build runs in both, and everything that differs between a browser and the
|
||||
app lives in `web/src/lib/platform.js`.
|
||||
|
||||
It talks to **any** Verdande server. The first screen asks for the address, checks
|
||||
it with `/api/v1/ping`, and then signs in there.
|
||||
|
||||
## How the app differs from a browser
|
||||
|
||||
| | Browser | App |
|
||||
|---|---|---|
|
||||
| Where the interface comes from | the server | inside the app, `capacitor://localhost` |
|
||||
| Which server | the one it was loaded from | the one its owner typed in |
|
||||
| Session | httpOnly cookie | a session token, sent as `Authorization: Bearer` |
|
||||
| Live sync | WebSocket with the cookie | WebSocket opened with a one-use ticket (`POST /ws-ticket`) |
|
||||
| Pictures and downloads | plain `<img>` and links | fetched with the token and shown from a blob (`nativemedia.js`) |
|
||||
| Service worker, Web Push | yes | no — WKWebView has neither |
|
||||
| Passkeys | yes | not yet — a passkey is bound to the server's domain |
|
||||
| Creating the first account | yes | no — done in a browser, then sign in from the app |
|
||||
|
||||
The server side of this is `client: "app"` on `/auth/login`, the
|
||||
`capacitor://localhost` CORS rule, and the ticket. All three are in
|
||||
`docs/openapi.yaml`.
|
||||
|
||||
An app session slides: every use moves its end forward, up to
|
||||
`VERDANDE_APP_SESSION_TTL` (180 days) unused. It is listed under *Enheder* in
|
||||
settings with the name the app gave itself, and can be signed out from there.
|
||||
|
||||
## Building
|
||||
|
||||
Everything up to Xcode runs anywhere, and runs in CI (`iOS app shell`):
|
||||
|
||||
```sh
|
||||
cd app
|
||||
npm ci
|
||||
npm run sync # builds web with VERDANDE_TARGET=app, copies it into ios/
|
||||
```
|
||||
|
||||
On a Mac with Xcode:
|
||||
|
||||
```sh
|
||||
npm run open # opens ios/App/App.xcodeproj
|
||||
```
|
||||
|
||||
Choose a simulator and press Run. In the app, type the address of a Verdande —
|
||||
`http://localhost:8080` reaches a dev server on the same Mac from the simulator.
|
||||
|
||||
Plain `http` is allowed to **local** addresses only (`NSAllowsLocalNetworking` in
|
||||
`Info.plist`), so a home server without a certificate works on the same network;
|
||||
anything on the internet has to be `https`.
|
||||
|
||||
`app/ios/` is committed. The web build inside it (`App/App/public`) and the
|
||||
generated config are not — `npm run sync` puts them there.
|
||||
|
||||
The icon and launch image come from `web/static/icon.svg`:
|
||||
|
||||
```sh
|
||||
cd web && node scripts/generate-ios-assets.js
|
||||
```
|
||||
|
||||
## Putting it on a phone
|
||||
|
||||
These steps are the account owner's — they involve Apple's terms, a payment and
|
||||
signing keys.
|
||||
|
||||
1. **Apple Developer Program** membership (99 USD a year) on the Apple ID that
|
||||
should own the app.
|
||||
2. In Xcode → *Signing & Capabilities*: choose that team. The bundle id is
|
||||
`dk.nolimit.verdande`; it can change until the first upload and never after.
|
||||
3. *Product → Archive*, then *Distribute App → TestFlight*.
|
||||
4. In App Store Connect, add testers to the TestFlight build.
|
||||
|
||||
## Not yet
|
||||
|
||||
- **Notifications.** Web Push does not exist in the app. Notifications on an
|
||||
iPhone go through Apple's APNs, and the key that sends them is bound to the
|
||||
app's bundle id — so it cannot live on every self-hosted server. That means a
|
||||
small relay run by the app's publisher, which every instance with an app user
|
||||
would talk to. That is a new connection from other people's servers, and it
|
||||
waits on a decision before it is built.
|
||||
- **The session token in the Keychain.** It is in the WebView's own storage for
|
||||
now, behind one object in `platform.js` so the Keychain plugin can replace it
|
||||
without anything else changing.
|
||||
- **Passkeys** in the app.
|
||||
- **Share sheet** (share text or a link *into* Verdande from another app).
|
||||
+54
-18
@@ -12,39 +12,70 @@ conversation.
|
||||
|
||||
## Connecting it
|
||||
|
||||
**Make a token** under **Settings → API tokens**. The page then shows the
|
||||
connector address to go with it — token and all — which is the thing to copy:
|
||||
**Add the connector** in Claude with your instance's address followed by `/mcp`,
|
||||
and nothing else:
|
||||
|
||||
```
|
||||
https://todo.example.dk/mcp?key=vrd_…
|
||||
https://todo.example.dk/mcp
|
||||
```
|
||||
|
||||
**Add the connector** in Claude with that address, and nothing else. The dialog
|
||||
asks for a name and a URL; leave the OAuth fields empty.
|
||||
Claude sends you to verdande to sign in — the ordinary sign-in, passkey and
|
||||
second factor included — and then asks one question: *"Claude wants to connect
|
||||
to your account"*. Press **Allow** and you are connected. Leave the dialog's
|
||||
OAuth client fields empty; Claude registers itself.
|
||||
|
||||
!!! note "Why the key is in the address"
|
||||
The dialog has no field for a bearer token — only OAuth client credentials —
|
||||
so a header cannot be configured from it. The address *is* the credential
|
||||
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
|
||||
Treat it like a password: anyone holding it reaches what you reach. The
|
||||
server logs request paths without their query strings, so it does not end up
|
||||
in the log.
|
||||
The approval screen shows two things: the name the program gave itself, and
|
||||
**where the access is sent**. Any program can call itself Claude; only Claude is
|
||||
answered at `claude.ai`. If that line says something you do not recognise, press
|
||||
**Deny**.
|
||||
|
||||
!!! warning "It has to be reachable from the internet"
|
||||
Claude connects from Anthropic's side, not from your browser. An instance only
|
||||
reachable on your LAN or over a VPN cannot be used this way.
|
||||
|
||||
### With a token in the address instead
|
||||
|
||||
A client that cannot sign in can be given a token in the address. **Make a token**
|
||||
under **Settings → API tokens**; the page then shows the address to go with it:
|
||||
|
||||
```
|
||||
https://todo.example.dk/mcp?key=vrd_…
|
||||
```
|
||||
|
||||
Treat that address like a password: anyone holding it reaches what you reach. The
|
||||
server logs request paths without their query strings, so it does not end up in
|
||||
the log.
|
||||
|
||||
### With a header instead
|
||||
|
||||
Anything that *can* send a header should, and use the API path:
|
||||
Anything that *can* send a header should:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer vrd_…" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
|
||||
https://todo.example.dk/api/v1/mcp
|
||||
https://todo.example.dk/mcp
|
||||
```
|
||||
|
||||
Both endpoints run the same server and reach the same things.
|
||||
`/api/v1/mcp` answers the same way. Both run the same server and reach the same
|
||||
things.
|
||||
|
||||
### How the sign-in works
|
||||
|
||||
verdande is its own OAuth authorization server, for MCP clients only — the shape
|
||||
the MCP specification asks for:
|
||||
|
||||
- `/.well-known/oauth-protected-resource/mcp` and
|
||||
`/.well-known/oauth-authorization-server` say where to sign in. An unsigned
|
||||
request to `/mcp` is answered 401 with a `WWW-Authenticate` header pointing at
|
||||
the first.
|
||||
- Clients register themselves at `/oauth/register`. Public clients only: a program
|
||||
running somewhere else cannot keep a secret, so PKCE (S256) is the proof
|
||||
instead. A registration nobody approves is removed after a day.
|
||||
- The code a client gets back is good for one minute and one exchange.
|
||||
- What it is exchanged for is an ordinary personal API token. No refresh token and
|
||||
no expiry: it is listed under **Settings → API tokens** with the client's name,
|
||||
marked *connected with your account*, and connecting the same client again
|
||||
replaces it rather than adding another.
|
||||
|
||||
## What Claude can do
|
||||
|
||||
@@ -56,6 +87,9 @@ Both endpoints run the same server and reach the same things.
|
||||
| `update_task` | Change a title, priority, date or project. |
|
||||
| `complete_task` | Tick one off. |
|
||||
| `add_comment` | Comment on a task. |
|
||||
| `search_notes` | Find notes by text. |
|
||||
| `create_note` | Write a note, as Markdown. |
|
||||
| `update_note` | Change a note. |
|
||||
|
||||
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
|
||||
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
|
||||
@@ -71,8 +105,10 @@ scope to configure.
|
||||
## What it cannot do
|
||||
|
||||
Delete projects, manage members, change settings or read another account. The tools
|
||||
are the six above; there is no general escape hatch.
|
||||
are the nine above; there is no general escape hatch.
|
||||
|
||||
!!! note "Revoking it"
|
||||
**Settings → API tokens**, delete the token. The connector stops working
|
||||
immediately, and nothing else about your account is affected.
|
||||
**Settings → API tokens**, delete the token — a connection made by signing in
|
||||
is listed there under the program's name. The connector stops working
|
||||
immediately, and asks you to sign in again the next time it is used. Nothing
|
||||
else about your account is affected.
|
||||
|
||||
+262
-10
@@ -3965,6 +3965,61 @@ paths:
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/oauth/request:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: What the approval page is approving.
|
||||
description: |
|
||||
Takes the `/oauth/authorize` query unchanged. Answers with the client's
|
||||
self-declared name and the host the code will be sent to — the part a
|
||||
client cannot misstate. Needs a session.
|
||||
security:
|
||||
- session: []
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
client_name: { type: string }
|
||||
redirect_to: { type: string, example: claude.ai }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/oauth/approve:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Approve or refuse a connection.
|
||||
description: |
|
||||
Needs a session — a token cannot approve, or one leaked token could
|
||||
mint others that outlive its revocation. Answers with the address to
|
||||
send the browser to: the client's, with `code` or `error=access_denied`.
|
||||
security:
|
||||
- session: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [query, approve]
|
||||
properties:
|
||||
query: { type: string, description: "The `/oauth/authorize` query string, unchanged." }
|
||||
approve: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Where to go next.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
redirect_to: { type: string }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
@@ -4024,14 +4079,51 @@ paths:
|
||||
Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`,
|
||||
`task.moved`, `task.deleted`, `comment.created`, `notification`,
|
||||
`reminder`.
|
||||
|
||||
A browser opens it with its session cookie and from the instance's own
|
||||
origin. The iOS app cannot put a header on a handshake, so it asks
|
||||
`/ws-ticket` first and opens `/ws?ticket=…`; a handshake from the app's
|
||||
origin without one is refused.
|
||||
parameters:
|
||||
- name: ticket
|
||||
in: query
|
||||
required: false
|
||||
schema: { type: string }
|
||||
description: From `/ws-ticket`. Good for one connection, within 30 seconds.
|
||||
responses:
|
||||
"101": { description: Switching protocols. }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/api/v1/ws-ticket:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: A one-use ticket for opening the live-sync socket without a cookie.
|
||||
description: |
|
||||
For the iOS app, whose session is a bearer token that a WebSocket
|
||||
handshake cannot carry. Putting the token itself in the URL would leave a
|
||||
credential lasting months in every log the URL passes through; the ticket
|
||||
lasts thirty seconds and opens one connection.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ticket: { type: string }
|
||||
expires_in: { type: integer, description: Seconds., example: 30 }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/api/v1/ping:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: A liveness check that does not touch the database.
|
||||
description: |
|
||||
Also how the iOS app tells a Verdande from anything else at an address
|
||||
somebody typed in, before anyone has signed in, and which half of the
|
||||
interface to show. It carries no version: `/version` is behind a login on
|
||||
purpose, and a public number is a list of the instances running an old one.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
@@ -4042,6 +4134,9 @@ paths:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string, const: ok }
|
||||
product: { type: string, const: verdande }
|
||||
edition: { type: string, enum: [full, notes] }
|
||||
api: { type: integer, const: 1, description: Raised when /api/v1 changes in a way an older app cannot follow. }
|
||||
|
||||
# --- data -----------------------------------------------------------------
|
||||
|
||||
@@ -4386,21 +4481,27 @@ paths:
|
||||
/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: The MCP endpoint, with the token in the query string.
|
||||
summary: The MCP endpoint at the address a person types into a connector.
|
||||
description: |
|
||||
The same server as `/api/v1/mcp`, for a client that can only be given a
|
||||
URL. Claude's custom-connector dialog asks for a name, an address and
|
||||
OAuth client credentials — there is no field for a bearer token — so the
|
||||
API path cannot be configured from it.
|
||||
The same server as `/api/v1/mcp`. Two credentials are accepted: a bearer
|
||||
token in `Authorization` — what a client holds after signing in with the
|
||||
account through `/oauth/authorize` — or a token in `?key=`, for a client
|
||||
that can only be given a URL.
|
||||
|
||||
The key is the only credential accepted here. A session cookie is
|
||||
ignored: this route sits outside the CSRF check, and honouring a cookie
|
||||
would make it a cross-site request that acts as whoever is signed in.
|
||||
security: []
|
||||
Without either, the 401 carries `WWW-Authenticate: Bearer
|
||||
resource_metadata="…/.well-known/oauth-protected-resource/mcp"`, which is
|
||||
how a client finds the sign-in.
|
||||
|
||||
A session cookie is ignored: this route sits outside the CSRF check, and
|
||||
honouring a cookie would make it a cross-site request that acts as
|
||||
whoever is signed in.
|
||||
security:
|
||||
- bearer: []
|
||||
- {}
|
||||
parameters:
|
||||
- name: key
|
||||
in: query
|
||||
required: true
|
||||
required: false
|
||||
schema: { type: string, example: vrd_… }
|
||||
description: A personal API token. Treat the whole address as a password.
|
||||
requestBody:
|
||||
@@ -4430,6 +4531,136 @@ paths:
|
||||
"202": { description: A notification. No body. }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
/.well-known/oauth-protected-resource:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Protected-resource metadata for the MCP endpoint (RFC 9728).
|
||||
security: []
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/ProtectedResource" }
|
||||
|
||||
/.well-known/oauth-protected-resource/mcp:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The same document, at the path RFC 9728 derives from `/mcp`.
|
||||
security: []
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/ProtectedResource" }
|
||||
|
||||
/.well-known/oauth-authorization-server:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Authorization-server metadata (RFC 8414).
|
||||
description: |
|
||||
The instance is its own authorization server, for MCP clients only.
|
||||
Authorization code with S256 PKCE, public clients, no refresh tokens.
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
issuer: { type: string }
|
||||
authorization_endpoint: { type: string }
|
||||
token_endpoint: { type: string }
|
||||
registration_endpoint: { type: string }
|
||||
code_challenge_methods_supported: { type: array, items: { type: string } }
|
||||
|
||||
/oauth/register:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Dynamic client registration (RFC 7591).
|
||||
description: |
|
||||
Open to anybody, rate limited, public clients only. Redirect addresses
|
||||
must be https, http on a loopback address (any port), or an app's own
|
||||
scheme. Metadata the server does not use is ignored. A registration that
|
||||
is never approved is removed after a day.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [redirect_uris]
|
||||
properties:
|
||||
client_name: { type: string }
|
||||
redirect_uris: { type: array, items: { type: string } }
|
||||
token_endpoint_auth_method: { type: string, enum: [none] }
|
||||
responses:
|
||||
"201":
|
||||
description: Registered.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
client_id: { type: string }
|
||||
client_name: { type: string }
|
||||
redirect_uris: { type: array, items: { type: string } }
|
||||
"400": { $ref: "#/components/responses/OAuthError" }
|
||||
"429": { description: Too many attempts. }
|
||||
|
||||
/oauth/authorize:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Where a client sends the browser to sign in and approve.
|
||||
description: |
|
||||
Checks the request and redirects to the app's approval page, `/forbind`,
|
||||
which signs the person in first if needed. An unknown client or an
|
||||
unregistered redirect address is refused here with 400, and nothing is
|
||||
sent anywhere; other problems are sent back to the client as `error=`.
|
||||
security: []
|
||||
parameters:
|
||||
- { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
|
||||
- { name: client_id, in: query, required: true, schema: { type: string } }
|
||||
- { name: redirect_uri, in: query, schema: { type: string } }
|
||||
- { name: state, in: query, schema: { type: string } }
|
||||
- { name: code_challenge, in: query, required: true, schema: { type: string } }
|
||||
- { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
|
||||
responses:
|
||||
"302": { description: "To the approval page, or back to the client with an error." }
|
||||
"400": { description: The client or its redirect address cannot be trusted. }
|
||||
|
||||
/oauth/token:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Exchange an authorization code for a token.
|
||||
description: |
|
||||
`authorization_code` only. The code is good for one minute and one
|
||||
attempt. The token is an ordinary personal API token, listed under
|
||||
Settings → Tokens with the client's name; connecting the same client
|
||||
again replaces it.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
required: [grant_type, code, client_id, code_verifier]
|
||||
properties:
|
||||
grant_type: { type: string, enum: [authorization_code] }
|
||||
code: { type: string }
|
||||
client_id: { type: string }
|
||||
redirect_uri: { type: string }
|
||||
code_verifier: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: The token.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
access_token: { type: string, example: vrd_… }
|
||||
token_type: { type: string, const: Bearer }
|
||||
"400": { $ref: "#/components/responses/OAuthError" }
|
||||
"429": { description: Too many attempts. }
|
||||
|
||||
/.well-known/caldav:
|
||||
get:
|
||||
tags: [integrations]
|
||||
@@ -4464,6 +4695,24 @@ components:
|
||||
browser's own session is never accepted here.
|
||||
|
||||
responses:
|
||||
ProtectedResource:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
resource: { type: string, example: https://todo.example.dk/mcp }
|
||||
authorization_servers: { type: array, items: { type: string } }
|
||||
OAuthError:
|
||||
description: The error shape of RFC 6749 §5.2.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error: { type: string, example: invalid_grant }
|
||||
error_description: { type: string }
|
||||
BadRequest:
|
||||
description: The body could not be read.
|
||||
content:
|
||||
@@ -5342,6 +5591,9 @@ components:
|
||||
created_at: { type: string, format: date-time }
|
||||
last_used_at: { type: string, format: date-time }
|
||||
expires_at: { type: string, format: date-time }
|
||||
connected:
|
||||
type: boolean
|
||||
description: "True for a token an app received by signing in with the account, rather than one made by hand."
|
||||
|
||||
Notification:
|
||||
type: object
|
||||
|
||||
@@ -69,6 +69,12 @@ type Config struct {
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// AppOrigins are origins treated like the iOS app's own, capacitor://localhost,
|
||||
// which is always included. Set by the end-to-end suite, which serves the
|
||||
// interface from a second host name to test it the way the app runs it. An
|
||||
// operator has no reason to set it.
|
||||
AppOrigins []string
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
@@ -165,6 +171,11 @@ func Load() (*Config, error) {
|
||||
Edition: env("VERDANDE_EDITION", EditionFull),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") {
|
||||
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
|
||||
c.AppOrigins = append(c.AppOrigins, o)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCORSAnswersTheAppAndNobodyElse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
preflight := map[string]string{
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization, content-type",
|
||||
}
|
||||
|
||||
resp := ts.corsDo(t, "OPTIONS", "/api/v1/projects", "capacitor://localhost", preflight)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("app preflight: status %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app preflight: Allow-Origin %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") {
|
||||
t.Errorf("app preflight: Allow-Headers %q lacks Authorization", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("credentials allowed (%q): the app uses a bearer, never a cookie", got)
|
||||
}
|
||||
|
||||
// An ordinary request from the app can be read.
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", "capacitor://localhost", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("app GET: Allow-Origin %q", got)
|
||||
}
|
||||
|
||||
// Anybody else gets nothing, so the browser keeps them from reading.
|
||||
for _, origin := range []string{"https://evil.example", "http://localhost", "capacitor://evil", "null"} {
|
||||
resp = ts.corsDo(t, "OPTIONS", "/api/v1/projects", origin, preflight)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: preflight answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
resp = ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("%s: GET answered with Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The app asks this before anyone has signed in, to tell a Verdande from a
|
||||
// router's login page.
|
||||
func TestPingNamesTheProductAndEditionButNoVersion(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
resp, body := anonymous.do(t, "GET", "/api/v1/ping", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ping: %d", resp.StatusCode)
|
||||
}
|
||||
if body["product"] != "verdande" || body["edition"] != "full" || body["api"] != float64(1) {
|
||||
t.Errorf("ping = %v", body)
|
||||
}
|
||||
if _, ok := body["version"]; ok {
|
||||
t.Error("ping reveals the version to anybody")
|
||||
}
|
||||
}
|
||||
|
||||
func dialWS(ctx context.Context, url, origin string) (*websocket.Conn, *http.Response, error) {
|
||||
h := http.Header{}
|
||||
if origin != "" {
|
||||
h.Set("Origin", origin)
|
||||
}
|
||||
return websocket.Dial(ctx, url, &websocket.DialOptions{HTTPHeader: h})
|
||||
}
|
||||
|
||||
func TestTheAppOpensLiveSyncWithATicket(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
token := body["session_token"].(string)
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Without a ticket or a cookie: refused.
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Fatal("a socket opened with no credential")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("no credential: %v", resp)
|
||||
}
|
||||
|
||||
resp, body := ts.appDo(t, token, "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket, _ := body["ticket"].(string)
|
||||
if resp.StatusCode != http.StatusOK || ticket == "" {
|
||||
t.Fatalf("ws-ticket: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
conn, _, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("dial with a ticket: %v", err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
|
||||
// And it is the right person's stream: their own new project arrives on it.
|
||||
ts.appDo(t, token, "POST", "/api/v1/projects", map[string]string{"name": "Live fra telefonen"})
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(msg), "Live fra telefonen") {
|
||||
t.Errorf("first event was %s", msg)
|
||||
}
|
||||
|
||||
// Spent: the same ticket does not open a second socket.
|
||||
if _, resp, err := dialWS(ctx, wsURL+"?ticket="+ticket, "capacitor://localhost"); err == nil {
|
||||
t.Error("a ticket opened two sockets")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("reused ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATicketExpires(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
_, body := appLogin(t, ts)
|
||||
_, body = ts.appDo(t, body["session_token"].(string), "POST", "/api/v1/ws-ticket", nil)
|
||||
ticket := body["ticket"].(string)
|
||||
|
||||
ts.api.wsTickets.mu.Lock()
|
||||
v := ts.api.wsTickets.m[ticket]
|
||||
v.expires = time.Now().Add(-time.Second)
|
||||
ts.api.wsTickets.m[ticket] = v
|
||||
ts.api.wsTickets.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?ticket=" + ticket
|
||||
if _, resp, err := dialWS(ctx, wsURL, "capacitor://localhost"); err == nil {
|
||||
t.Error("an expired ticket opened a socket")
|
||||
} else if resp == nil || resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expired ticket: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie path is unchanged: a foreign page cannot open a socket that rides
|
||||
// on the cookie, and the instance's own page can.
|
||||
func TestACookieSocketStillChecksTheOrigin(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
var cookie string
|
||||
for _, c := range ts.client.Jar.Cookies(mustURL(t, ts.URL)) {
|
||||
if c.Name == sessionCookieInsecure {
|
||||
cookie = c.Value
|
||||
}
|
||||
}
|
||||
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dial := func(origin string) error {
|
||||
h := http.Header{}
|
||||
h.Set("Origin", origin)
|
||||
h.Set("Cookie", sessionCookieInsecure+"="+cookie)
|
||||
conn, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
|
||||
if err == nil {
|
||||
conn.CloseNow()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := dial("https://evil.example"); err == nil {
|
||||
t.Error("a foreign page opened a socket on the cookie")
|
||||
}
|
||||
if err := dial("capacitor://localhost"); err == nil {
|
||||
t.Error("the app origin opened a socket on a cookie, without a ticket")
|
||||
}
|
||||
if err := dial("http://localhost"); err != nil {
|
||||
t.Errorf("control: the instance's own origin: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The end-to-end suite runs the interface from a second host name and tells the
|
||||
// server to count it as the app's. That origin is then the app's in every
|
||||
// respect, and capacitor://localhost stays one too.
|
||||
func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.AppOrigins = []string{"http://app.localhost:8097"}
|
||||
})
|
||||
for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} {
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Errorf("%s: Allow-Origin %q", origin, got)
|
||||
}
|
||||
}
|
||||
// Not a prefix match: a neighbour on another port is nobody.
|
||||
resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("a neighbouring origin was answered: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the iOS app needs from a server that a browser gets for free by being on
|
||||
// the same origin. The app loads its interface from capacitor://localhost and
|
||||
// talks to whichever server its owner typed in, so every request it makes is
|
||||
// cross-origin: it needs CORS to read the answers, a way to open the live-sync
|
||||
// socket without a cookie, and a way to ask "is this a Verdande?" before anyone
|
||||
// has signed in.
|
||||
|
||||
// appCORS answers the app's origin, and nobody else's.
|
||||
//
|
||||
// No Access-Control-Allow-Credentials: the app carries its session as a bearer
|
||||
// token, never a cookie, so a response to a request with a cookie on it is not
|
||||
// something this header ever needs to unlock. Every other origin gets no CORS
|
||||
// headers at all, exactly as before — the browser then keeps a foreign page from
|
||||
// reading anything, which is the default this must not loosen.
|
||||
func (s *Server) appCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if !s.isAppOrigin(origin) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
// Read by the client: a rate limit's wait, and a download's file name.
|
||||
h.Set("Access-Control-Expose-Headers", "Retry-After, Content-Disposition")
|
||||
|
||||
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
|
||||
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- the live-sync socket without a cookie -----------------------------------
|
||||
|
||||
// A browser cannot put a header on a WebSocket handshake, so the app's bearer
|
||||
// token has no way onto it. Putting the token in the URL would work and would
|
||||
// leave a credential that lasts months in every proxy log the URL passes. A
|
||||
// ticket is the usual answer: asked for with the token, good for one connection,
|
||||
// gone in thirty seconds.
|
||||
|
||||
const wsTicketTTL = 30 * time.Second
|
||||
|
||||
type wsTicket struct {
|
||||
userID string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type wsTickets struct {
|
||||
mu sync.Mutex
|
||||
m map[string]wsTicket
|
||||
}
|
||||
|
||||
func newWSTickets() *wsTickets { return &wsTickets{m: map[string]wsTicket{}} }
|
||||
|
||||
func (t *wsTickets) issue(userID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ticket := hex.EncodeToString(raw)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
// Swept on the way in, so the map is bounded by thirty seconds of asking.
|
||||
now := time.Now()
|
||||
for k, v := range t.m {
|
||||
if now.After(v.expires) {
|
||||
delete(t.m, k)
|
||||
}
|
||||
}
|
||||
t.m[ticket] = wsTicket{userID: userID, expires: now.Add(wsTicketTTL)}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// redeem spends a ticket. It is deleted before it is checked, so a second
|
||||
// presentation finds nothing, whether the first one was in time or not.
|
||||
func (t *wsTickets) redeem(ticket string) (string, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
v, ok := t.m[ticket]
|
||||
delete(t.m, ticket)
|
||||
if !ok || time.Now().After(v.expires) {
|
||||
return "", false
|
||||
}
|
||||
return v.userID, true
|
||||
}
|
||||
|
||||
func (s *Server) handleWSTicket(w http.ResponseWriter, r *http.Request) {
|
||||
ticket, err := s.wsTickets.issue(userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "issue websocket ticket", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ticket": ticket,
|
||||
"expires_in": int(wsTicketTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebSocketEntry opens the socket with a ticket, or falls through to the
|
||||
// ordinary session check.
|
||||
//
|
||||
// A socket opened with a ticket skips the origin check, and that is the point
|
||||
// rather than a gap: the origin check exists because a browser attaches the
|
||||
// cookie to a handshake from any page. A ticket is attached by nobody — it has to
|
||||
// be asked for with a credential and put in the URL on purpose — so there is no
|
||||
// ambient authority for a foreign page to borrow.
|
||||
func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
if ticket == "" {
|
||||
// The app never holds a cookie, so a handshake from its origin without a
|
||||
// ticket is not the app. Refused here rather than left to the origin
|
||||
// check, which compares host names only: capacitor://localhost and an
|
||||
// instance served at http://localhost have the same one.
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket")
|
||||
return
|
||||
}
|
||||
s.requireAuth(http.HandlerFunc(s.handleWebSocket)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
userID, ok := s.wsTickets.redeem(ticket)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that ticket is not valid")
|
||||
return
|
||||
}
|
||||
user, err := s.db.UserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "load user", err)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(withUser(r.Context(), user), ctxTicket, true)
|
||||
s.handleWebSocket(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// --- is this a Verdande? -----------------------------------------------------
|
||||
|
||||
// handlePing answers before anybody has signed in, which is when the app needs
|
||||
// it: the person has typed an address and the app has to tell "that is a
|
||||
// Verdande" from "that is a router's login page" before it shows a password
|
||||
// field for it. The edition decides which half of the interface to show.
|
||||
//
|
||||
// No version. /version sits behind a login on purpose, and a public number is a
|
||||
// list of the instances still running an old one.
|
||||
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"product": "verdande",
|
||||
"edition": s.edition(),
|
||||
"api": 1,
|
||||
})
|
||||
}
|
||||
@@ -185,6 +185,10 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
// er værktøjslisten bagved, der er kortere.
|
||||
"POST /api/v1/mcp",
|
||||
"POST /mcp",
|
||||
// Og at forbinde med kontoen, som hører til MCP og ikke til opgaverne.
|
||||
"GET /.well-known/oauth-authorization-server",
|
||||
"POST /oauth/token",
|
||||
"POST /api/v1/oauth/approve",
|
||||
} {
|
||||
if !notes[must] {
|
||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||
|
||||
@@ -1172,17 +1172,16 @@ func TestMailAddressPrefersTheConfiguredSender(t *testing.T) {
|
||||
// namespace, and a client probing it is asking a yes-or-no question.
|
||||
//
|
||||
// Claude asks /.well-known/oauth-authorization-server before connecting to an
|
||||
// MCP server. It read the shell as "there is an authorization server here" and
|
||||
// then failed trying to register with it, reporting a broken sign-in service on
|
||||
// a server that has none and needs none.
|
||||
// MCP server. Before that was a real route it read the shell as "there is an
|
||||
// authorization server here" and failed trying to register with it. The OAuth
|
||||
// paths are answered by oauth_test.go now; everything else must still be a 404.
|
||||
func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, path := range []string{
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/oauth-protected-resource",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/oauth-protected-resource/somewhere-else",
|
||||
"/.well-known/anything-at-all",
|
||||
} {
|
||||
resp, err := ts.client.Get(ts.URL + path)
|
||||
|
||||
@@ -138,26 +138,34 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleMCPWithKey is the same endpoint for a client that can only be given a
|
||||
// URL: `POST /mcp?key=vrd_…`.
|
||||
// handleMCPWithKey is the same endpoint at the address a person types into a
|
||||
// connector dialog: `POST /mcp`. Two credentials are accepted here.
|
||||
//
|
||||
// Claude's custom-connector dialog asks for a name, an address and — under
|
||||
// advanced settings — an OAuth client id and secret. There is no field for a
|
||||
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
|
||||
// address is. The calendar feed already carries its credential in the URL for
|
||||
// the same reason, and the request logger records the path without the query, so
|
||||
// the key does not end up in the log.
|
||||
// `Authorization: Bearer vrd_…` is what a client sends after signing in with the
|
||||
// account (oauth_handlers.go), and what anything holding a token by hand can
|
||||
// send as well.
|
||||
//
|
||||
// The key is the *only* thing accepted here. Reading a session cookie instead
|
||||
// would make this a state-changing POST outside the CSRF check — which is to say
|
||||
// a cross-site request that acts as whoever is signed in.
|
||||
// `?key=vrd_…` is for a client that can only be given a URL. Claude's
|
||||
// custom-connector dialog has no field for a header, so before the sign-in
|
||||
// existed this was the only way to configure it, and addresses made that way
|
||||
// still work. The calendar feed carries its credential in the URL for the same
|
||||
// reason, and the request logger records the path without the query, so the key
|
||||
// does not end up in the log.
|
||||
//
|
||||
// A session cookie is *never* accepted here. That would make this a
|
||||
// state-changing POST outside the CSRF check — a cross-site request that acts as
|
||||
// whoever is signed in. A bearer header is safe where a cookie is not: a browser
|
||||
// will not attach one to a cross-site request by itself.
|
||||
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.URL.Query().Get("key")
|
||||
if h := r.Header.Get("Authorization"); key == "" && strings.HasPrefix(h, "Bearer ") {
|
||||
key = strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||||
}
|
||||
if key == "" || !auth.IsAPIToken(key) {
|
||||
// 401 rather than 404: unlike a task, the existence of this endpoint is
|
||||
// not a secret — it is written in the documentation.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"add ?key=<api token> to the URL")
|
||||
// not a secret — it is written in the documentation. And the header is
|
||||
// what starts the sign-in: it tells the client where to read how.
|
||||
s.mcpUnauthorized(w, "sign in, or add ?key=<api token> to the URL")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,13 +175,22 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, r, "mcp key", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||
// Also the answer to a token revoked under Settings, which is why the
|
||||
// header is here too: the client then asks the person to sign in again.
|
||||
s.mcpUnauthorized(w, "that key is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
|
||||
}
|
||||
|
||||
// mcpUnauthorized is the 401 that points a client at the sign-in (RFC 9728 §5.1).
|
||||
func (s *Server) mcpUnauthorized(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("WWW-Authenticate",
|
||||
`Bearer resource_metadata="`+s.protectedResourceMetadataURL()+`"`)
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, message)
|
||||
}
|
||||
|
||||
// --- tool implementations ------------------------------------------------------------
|
||||
|
||||
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
|
||||
@@ -18,6 +18,8 @@ type ctxKey int
|
||||
const (
|
||||
ctxUser ctxKey = iota
|
||||
ctxSession
|
||||
// ctxTicket marks a socket opened with a one-use ticket rather than a cookie.
|
||||
ctxTicket
|
||||
)
|
||||
|
||||
// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is
|
||||
@@ -220,7 +222,7 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
// this Origin — the browser writes the header, not the page — and an app
|
||||
// login sets no cookie, so there is no session here for a forged request
|
||||
// to ride on.
|
||||
if isAppOrigin(r.Header.Get("Origin")) {
|
||||
if s.isAppOrigin(r.Header.Get("Origin")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -249,12 +251,20 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
// makes to a server arrives from here, whichever server it is.
|
||||
var appOrigins = []string{"capacitor://localhost"}
|
||||
|
||||
func isAppOrigin(origin string) bool {
|
||||
func (s *Server) isAppOrigin(origin string) bool {
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
for _, o := range appOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, o := range s.cfg.AppOrigins {
|
||||
if origin == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Signing in to MCP with the account, rather than with a token pasted into a URL.
|
||||
//
|
||||
// Claude's connector dialog tries OAuth before anything else: it asks
|
||||
// /.well-known for an authorization server, registers itself, sends the person
|
||||
// to sign in and approve, and comes back with a code. Until this existed the
|
||||
// answer was a 404, and the only way in was to make a token under Settings and
|
||||
// paste it into the address — a secret copied between two programs, which is the
|
||||
// step people stopped at.
|
||||
//
|
||||
// The shape is the smallest one the MCP authorization spec asks for:
|
||||
//
|
||||
// - discovery: protected-resource metadata (RFC 9728) pointing at this instance
|
||||
// as its own authorization server (RFC 8414);
|
||||
// - dynamic client registration (RFC 7591), public clients only — a program
|
||||
// running somewhere else cannot keep a secret, so PKCE is the proof instead;
|
||||
// - the authorization-code grant with S256 PKCE, and nothing else.
|
||||
//
|
||||
// What an approval hands out is an ordinary personal API token. That is the
|
||||
// whole permission model: a token is a person, it is listed under Settings →
|
||||
// Tokens next to the hand-made ones, and revoking it there disconnects the
|
||||
// client. No refresh token and no expiry, because there is nothing for one to
|
||||
// protect that the token page does not already — a client that is cut off is
|
||||
// answered 401 and asks the person to sign in again.
|
||||
|
||||
const oauthCodeTTL = time.Minute
|
||||
|
||||
// oauthGrant is what an authorization code stands for until it is exchanged.
|
||||
type oauthGrant struct {
|
||||
userID string
|
||||
clientID string
|
||||
redirectURI string
|
||||
challenge string
|
||||
scope string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// oauthCodes are held in memory, like the socket tickets: good for a minute and
|
||||
// once. A restart in that minute costs one more click, which is cheaper than a
|
||||
// table of short-lived secrets.
|
||||
type oauthCodes struct {
|
||||
mu sync.Mutex
|
||||
m map[string]oauthGrant
|
||||
}
|
||||
|
||||
func newOAuthCodes() *oauthCodes { return &oauthCodes{m: map[string]oauthGrant{}} }
|
||||
|
||||
func (c *oauthCodes) issue(g oauthGrant) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
code := hex.EncodeToString(raw)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k, v := range c.m {
|
||||
if now.After(v.expires) {
|
||||
delete(c.m, k)
|
||||
}
|
||||
}
|
||||
g.expires = now.Add(oauthCodeTTL)
|
||||
c.m[code] = g
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// redeem spends a code. Deleted before it is checked, so a code presented twice
|
||||
// fails the second time whatever the first one did — a replayed code is the
|
||||
// thing PKCE and single use are both there to stop.
|
||||
func (c *oauthCodes) redeem(code string) (oauthGrant, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
g, ok := c.m[code]
|
||||
delete(c.m, code)
|
||||
if !ok || time.Now().After(g.expires) {
|
||||
return oauthGrant{}, false
|
||||
}
|
||||
return g, true
|
||||
}
|
||||
|
||||
// --- discovery -----------------------------------------------------------------------
|
||||
|
||||
// mcpResource is the address the protected resource is known by: the URL a
|
||||
// person types into the connector dialog.
|
||||
func (s *Server) mcpResource() string { return s.cfg.BaseURL + "/mcp" }
|
||||
|
||||
func (s *Server) protectedResourceMetadataURL() string {
|
||||
return s.cfg.BaseURL + "/.well-known/oauth-protected-resource/mcp"
|
||||
}
|
||||
|
||||
func (s *Server) handleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
openCORS(w)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"resource": s.mcpResource(),
|
||||
"authorization_servers": []string{s.cfg.BaseURL},
|
||||
"bearer_methods_supported": []string{"header"},
|
||||
"resource_name": s.cfg.ProductSlug(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthorizationServerMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
openCORS(w)
|
||||
base := s.cfg.BaseURL
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": base + "/oauth/authorize",
|
||||
"token_endpoint": base + "/oauth/token",
|
||||
"registration_endpoint": base + "/oauth/register",
|
||||
"response_types_supported": []string{"code"},
|
||||
"grant_types_supported": []string{"authorization_code"},
|
||||
"code_challenge_methods_supported": []string{"S256"},
|
||||
"token_endpoint_auth_methods_supported": []string{"none"},
|
||||
})
|
||||
}
|
||||
|
||||
// openCORS lets a browser-based client read these answers. Safe for exactly the
|
||||
// routes it is on: none of them reads a cookie, so a cross-site caller learns
|
||||
// nothing and can do nothing it could not do with curl.
|
||||
func openCORS(w http.ResponseWriter) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
}
|
||||
|
||||
func handleOpenPreflight(w http.ResponseWriter, r *http.Request) {
|
||||
openCORS(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// oauthError is the error shape RFC 6749 §5.2 prescribes for the token and
|
||||
// registration endpoints — a client library parses `error`, not our `code`.
|
||||
func oauthError(w http.ResponseWriter, status int, code, description string) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
|
||||
}
|
||||
|
||||
// --- registration --------------------------------------------------------------------
|
||||
|
||||
type registerRequest struct {
|
||||
ClientName string `json:"client_name"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
|
||||
}
|
||||
|
||||
func (s *Server) handleOAuthRegister(w http.ResponseWriter, r *http.Request) {
|
||||
openCORS(w)
|
||||
|
||||
// Not decodeJSON: a registration request carries whatever metadata the
|
||||
// client library fills in — logo_uri, grant_types, scope, contacts — and
|
||||
// RFC 7591 says a server ignores what it does not use. Refusing unknown
|
||||
// fields here would refuse every real client.
|
||||
var req registerRequest
|
||||
if err := decodeLenient(r, &req); err != nil {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_client_metadata", "the body is not JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.RedirectURIs) == 0 || len(req.RedirectURIs) > 10 {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_redirect_uri",
|
||||
"between one and ten redirect_uris are required")
|
||||
return
|
||||
}
|
||||
for _, u := range req.RedirectURIs {
|
||||
if !validRedirectURI(u) {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_redirect_uri",
|
||||
"redirect_uris must be https, http on a loopback address, or an app's own scheme: "+u)
|
||||
return
|
||||
}
|
||||
}
|
||||
if m := req.TokenEndpointAuthMethod; m != "" && m != "none" {
|
||||
// Said rather than silently downgraded: a client that believes it holds a
|
||||
// secret would send one and wonder why it was ignored.
|
||||
oauthError(w, http.StatusBadRequest, "invalid_client_metadata",
|
||||
"only public clients are supported; use token_endpoint_auth_method \"none\" with PKCE")
|
||||
return
|
||||
}
|
||||
|
||||
name := cleanClientName(req.ClientName)
|
||||
client, err := s.db.CreateOAuthClient(r.Context(), name, req.RedirectURIs)
|
||||
if err != nil {
|
||||
s.internal(w, r, "register oauth client", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"client_id": client.ID,
|
||||
"client_id_issued_at": client.CreatedAt.Unix(),
|
||||
"client_name": client.Name,
|
||||
"redirect_uris": client.RedirectURIs,
|
||||
"grant_types": []string{"authorization_code"},
|
||||
"response_types": []string{"code"},
|
||||
"token_endpoint_auth_method": "none",
|
||||
})
|
||||
}
|
||||
|
||||
// cleanClientName makes the self-declared name fit to show. It is shown on the
|
||||
// approval screen and on the tokens page, so it is trimmed, stripped of control
|
||||
// characters and capped — and it is never trusted to say who the client is: the
|
||||
// approval screen also shows where the code will be sent, which a client cannot
|
||||
// choose to misstate.
|
||||
func cleanClientName(name string) string {
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(name))
|
||||
if runes := []rune(name); len(runes) > 60 {
|
||||
name = strings.TrimSpace(string(runes[:60]))
|
||||
}
|
||||
if name == "" {
|
||||
name = "MCP-klient"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// validRedirectURI is the rule for where a code may be sent.
|
||||
//
|
||||
// https anywhere; plain http only to this machine's loopback (RFC 8252 §7.3,
|
||||
// where a desktop client listens on a port it picks at run time); and an app's
|
||||
// own scheme (§7.1), which is how Cursor and its like receive one. The schemes a
|
||||
// browser would *run* rather than navigate to are refused by name — the approval
|
||||
// page hands the address to `location`, and `javascript:` there is script
|
||||
// running on this origin.
|
||||
func validRedirectURI(raw string) bool {
|
||||
if len(raw) > 2000 {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Fragment != "" || u.Scheme == "" {
|
||||
return false
|
||||
}
|
||||
switch scheme := strings.ToLower(u.Scheme); scheme {
|
||||
case "https":
|
||||
return u.Host != ""
|
||||
case "http":
|
||||
return isLoopback(u.Hostname())
|
||||
case "javascript", "data", "file", "vbscript", "blob", "about", "ws", "wss", "ftp":
|
||||
return false
|
||||
default:
|
||||
// The scheme's own syntax, RFC 3986 §3.1. url.Parse is lenient about it.
|
||||
for i, r := range scheme {
|
||||
if !(r >= 'a' && r <= 'z' || i > 0 && (r >= '0' && r <= '9' || r == '+' || r == '-' || r == '.')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// redirectAllowed reports whether got is one of the client's registered
|
||||
// addresses. Exact, except that a loopback address may differ in its port: a
|
||||
// desktop client registers once and listens wherever the OS lets it next time.
|
||||
func redirectAllowed(registered []string, got string) bool {
|
||||
for _, want := range registered {
|
||||
if want == got {
|
||||
return true
|
||||
}
|
||||
}
|
||||
g, err := url.Parse(got)
|
||||
if err != nil || g.Scheme != "http" || !isLoopback(g.Hostname()) {
|
||||
return false
|
||||
}
|
||||
for _, want := range registered {
|
||||
w, err := url.Parse(want)
|
||||
if err != nil || w.Scheme != "http" || !isLoopback(w.Hostname()) {
|
||||
continue
|
||||
}
|
||||
if w.Hostname() == g.Hostname() && w.Path == g.Path && w.RawQuery == g.RawQuery {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- authorization -------------------------------------------------------------------
|
||||
|
||||
// authorizeRequest is a checked /oauth/authorize query.
|
||||
type authorizeRequest struct {
|
||||
client *store.OAuthClient
|
||||
redirectURI string
|
||||
state string
|
||||
challenge string
|
||||
scope string
|
||||
}
|
||||
|
||||
// errBadClient means the client or its redirect address could not be trusted,
|
||||
// so the answer must NOT be sent to that address (RFC 6749 §4.1.2.1) — it is
|
||||
// shown to the person instead. Every other problem goes back to the client.
|
||||
var errBadClient = errors.New("bad client")
|
||||
|
||||
// checkAuthorize validates a query. It runs twice — when the browser arrives,
|
||||
// and again when the person presses approve — because the approval page is a
|
||||
// page in the app, and what it posts back is whatever is in its address bar.
|
||||
func (s *Server) checkAuthorize(r *http.Request, q url.Values) (*authorizeRequest, string, error) {
|
||||
client, err := s.db.OAuthClient(r.Context(), q.Get("client_id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, "this app is not registered here — remove the connection and add it again", errBadClient
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
redirect := q.Get("redirect_uri")
|
||||
if redirect == "" && len(client.RedirectURIs) == 1 {
|
||||
redirect = client.RedirectURIs[0]
|
||||
}
|
||||
if !redirectAllowed(client.RedirectURIs, redirect) {
|
||||
return nil, "the app asked to be answered at an address it did not register", errBadClient
|
||||
}
|
||||
|
||||
req := &authorizeRequest{
|
||||
client: client, redirectURI: redirect,
|
||||
state: q.Get("state"), challenge: q.Get("code_challenge"), scope: q.Get("scope"),
|
||||
}
|
||||
switch {
|
||||
case q.Get("response_type") != "code":
|
||||
return req, "unsupported_response_type", nil
|
||||
case req.challenge == "" || q.Get("code_challenge_method") != "S256":
|
||||
// OAuth 2.1 makes PKCE mandatory, and plain PKCE protects nothing that
|
||||
// S256 does not, so it is S256 or no code.
|
||||
return req, "invalid_request", nil
|
||||
}
|
||||
return req, "", nil
|
||||
}
|
||||
|
||||
// redirectWith builds the address the browser is sent back to. Callers include
|
||||
// `iss` (RFC 9207): it says who answered, so a client talking to several servers
|
||||
// cannot be handed one server's code by another.
|
||||
func (a *authorizeRequest) redirectWith(params url.Values) string {
|
||||
if a.state != "" {
|
||||
params.Set("state", a.state)
|
||||
}
|
||||
sep := "?"
|
||||
if strings.Contains(a.redirectURI, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return a.redirectURI + sep + params.Encode()
|
||||
}
|
||||
|
||||
// handleOAuthAuthorize is where the client sends the browser. It checks the
|
||||
// request and hands over to the app's approval page, which signs the person in
|
||||
// first if it needs to — the same sign-in screen, passkeys and second factor
|
||||
// included, rather than a second, lesser one written for OAuth.
|
||||
func (s *Server) handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
req, problem, err := s.checkAuthorize(r, q)
|
||||
if errors.Is(err, errBadClient) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(s.cfg.ProductSlug() + ": " + problem + ".\n"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.internal(w, r, "oauth authorize", err)
|
||||
return
|
||||
}
|
||||
if problem != "" {
|
||||
http.Redirect(w, r, req.redirectWith(url.Values{
|
||||
"error": {problem}, "iss": {s.cfg.BaseURL},
|
||||
}), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/forbind?"+r.URL.RawQuery, http.StatusFound)
|
||||
}
|
||||
|
||||
// handleOAuthRequest tells the approval page what it is approving.
|
||||
func (s *Server) handleOAuthRequest(w http.ResponseWriter, r *http.Request) {
|
||||
req, problem, err := s.checkAuthorize(r, r.URL.Query())
|
||||
if err != nil && !errors.Is(err, errBadClient) {
|
||||
s.internal(w, r, "oauth request", err)
|
||||
return
|
||||
}
|
||||
if err == nil && problem != "" {
|
||||
problem = "the app's request is not valid (" + problem + ")"
|
||||
}
|
||||
if problem != "" {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, problem)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"client_name": req.client.Name,
|
||||
// Where the code goes, which is the part a client cannot lie about. Any
|
||||
// program can register itself as "Claude"; only Claude is answered at
|
||||
// claude.ai.
|
||||
"redirect_to": redirectLabel(req.redirectURI),
|
||||
})
|
||||
}
|
||||
|
||||
func redirectLabel(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if u.Host != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
return u.Scheme + ":"
|
||||
}
|
||||
|
||||
type approveRequest struct {
|
||||
// Query is the approval page's own query string, exactly as the client sent
|
||||
// it. Posted whole rather than as fields, so this and handleOAuthAuthorize
|
||||
// read one thing with one function and cannot drift apart.
|
||||
Query string `json:"query"`
|
||||
Approve bool `json:"approve"`
|
||||
}
|
||||
|
||||
// handleOAuthApprove is the person's answer. Behind requireSession: an approval
|
||||
// mints a token, and a token must not be able to mint another.
|
||||
func (s *Server) handleOAuthApprove(w http.ResponseWriter, r *http.Request) {
|
||||
var body approveRequest
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
return
|
||||
}
|
||||
q, err := url.ParseQuery(strings.TrimPrefix(body.Query, "?"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "the request could not be read")
|
||||
return
|
||||
}
|
||||
req, problem, err := s.checkAuthorize(r, q)
|
||||
if err != nil && !errors.Is(err, errBadClient) {
|
||||
s.internal(w, r, "oauth approve", err)
|
||||
return
|
||||
}
|
||||
if err == nil && problem != "" {
|
||||
problem = "the app's request is not valid (" + problem + ")"
|
||||
}
|
||||
if problem != "" {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, problem)
|
||||
return
|
||||
}
|
||||
|
||||
if !body.Approve {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"redirect_to": req.redirectWith(url.Values{
|
||||
"error": {"access_denied"}, "iss": {s.cfg.BaseURL},
|
||||
})})
|
||||
return
|
||||
}
|
||||
|
||||
code, err := s.oauthCodes.issue(oauthGrant{
|
||||
userID: userFrom(r.Context()).ID, clientID: req.client.ID,
|
||||
redirectURI: req.redirectURI, challenge: req.challenge, scope: req.scope,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, r, "oauth code", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"redirect_to": req.redirectWith(url.Values{
|
||||
"code": {code}, "iss": {s.cfg.BaseURL},
|
||||
})})
|
||||
}
|
||||
|
||||
// --- token ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleOAuthToken(w http.ResponseWriter, r *http.Request) {
|
||||
openCORS(w)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_request", "the body must be form-encoded")
|
||||
return
|
||||
}
|
||||
if gt := r.PostForm.Get("grant_type"); gt != "authorization_code" {
|
||||
oauthError(w, http.StatusBadRequest, "unsupported_grant_type",
|
||||
"only authorization_code is supported")
|
||||
return
|
||||
}
|
||||
|
||||
clientID := r.PostForm.Get("client_id")
|
||||
if id, _, ok := r.BasicAuth(); ok && clientID == "" {
|
||||
clientID = id
|
||||
}
|
||||
|
||||
grant, ok := s.oauthCodes.redeem(r.PostForm.Get("code"))
|
||||
if !ok {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_grant", "the code is unknown, used or expired")
|
||||
return
|
||||
}
|
||||
// Each of these is a code presented by somebody other than the one it was
|
||||
// issued to, or for somewhere else. The code is already spent either way.
|
||||
if clientID != grant.clientID {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_grant", "the code was issued to another client")
|
||||
return
|
||||
}
|
||||
if ru := r.PostForm.Get("redirect_uri"); ru != "" && ru != grant.redirectURI {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_grant", "redirect_uri does not match the authorization")
|
||||
return
|
||||
}
|
||||
if !pkceMatches(r.PostForm.Get("code_verifier"), grant.challenge) {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_grant", "code_verifier does not match")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := s.db.OAuthClient(r.Context(), grant.clientID)
|
||||
if err != nil {
|
||||
oauthError(w, http.StatusBadRequest, "invalid_client", "the client is no longer registered")
|
||||
return
|
||||
}
|
||||
token, err := s.db.CreateOAuthToken(r.Context(), grant.userID, client)
|
||||
if err != nil {
|
||||
s.internal(w, r, "oauth token", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
out := map[string]string{"access_token": token, "token_type": "Bearer"}
|
||||
if grant.scope != "" {
|
||||
out["scope"] = grant.scope
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// pkceMatches is RFC 7636 §4.6 for S256: the challenge is the unpadded base64url
|
||||
// SHA-256 of the verifier.
|
||||
func pkceMatches(verifier, challenge string) bool {
|
||||
if len(verifier) < 43 || len(verifier) > 128 {
|
||||
return false
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return subtle.ConstantTimeCompare([]byte(want), []byte(challenge)) == 1
|
||||
}
|
||||
|
||||
// decodeLenient reads a JSON body that may carry fields this server does not
|
||||
// use. Only for the registration endpoint — see the comment there.
|
||||
func decodeLenient(r *http.Request, dst any) error {
|
||||
return json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(dst)
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Signing in to MCP with the account: the whole dance Claude performs, from the
|
||||
// first 401 to a tool call made with the token it was handed.
|
||||
|
||||
const claudeCallback = "https://claude.ai/api/mcp/auth_callback"
|
||||
|
||||
// A verifier of the minimum legal length, and its S256 challenge.
|
||||
var (
|
||||
testVerifier = strings.Repeat("v", 43)
|
||||
testChallenge = func() string {
|
||||
sum := sha256.Sum256([]byte(testVerifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}()
|
||||
)
|
||||
|
||||
func postJSON(t *testing.T, target, body string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
resp, err := http.Post(target, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var decoded map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&decoded)
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
// registerClient registers the way Claude does, extra metadata included.
|
||||
func registerClient(t *testing.T, ts *testServer, redirect string) string {
|
||||
t.Helper()
|
||||
resp, body := postJSON(t, ts.URL+"/oauth/register", `{
|
||||
"client_name": "Claude",
|
||||
"redirect_uris": ["`+redirect+`"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"scope": "claudeai"
|
||||
}`)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("register: status %d, %v", resp.StatusCode, body)
|
||||
}
|
||||
id, _ := body["client_id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("register: no client_id in %v", body)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func authorizeQuery(clientID, redirect string) string {
|
||||
return url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirect},
|
||||
"state": {"st4te"},
|
||||
"code_challenge": {testChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"resource": {"http://localhost/mcp"},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// approve is the approval page pressing "Tillad", as the signed-in ts.client.
|
||||
// It returns the code from the address the browser is sent back to.
|
||||
func approve(t *testing.T, ts *testServer, query string) string {
|
||||
t.Helper()
|
||||
resp, body := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{
|
||||
"query": "?" + query, "approve": true,
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("approve: status %d, %v", resp.StatusCode, body)
|
||||
}
|
||||
back, err := url.Parse(body["redirect_to"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := back.Query().Get("state"); got != "st4te" {
|
||||
t.Errorf("state = %q, want it handed back unchanged", got)
|
||||
}
|
||||
return back.Query().Get("code")
|
||||
}
|
||||
|
||||
func exchange(t *testing.T, ts *testServer, form url.Values) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
resp, err := http.PostForm(ts.URL+"/oauth/token", form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var decoded map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&decoded)
|
||||
return resp, decoded
|
||||
}
|
||||
|
||||
func codeForm(clientID, code string) url.Values {
|
||||
return url.Values{
|
||||
"grant_type": {"authorization_code"}, "code": {code}, "client_id": {clientID},
|
||||
"redirect_uri": {claudeCallback}, "code_verifier": {testVerifier},
|
||||
}
|
||||
}
|
||||
|
||||
func mcpInitialize(t *testing.T, ts *testServer, bearer string) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/mcp",
|
||||
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestMCPSignInWithTheAccount(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// 1. The connector's first request has no credential, and the answer says
|
||||
// where to read how to get one.
|
||||
resp := mcpInitialize(t, ts, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated /mcp: status %d", resp.StatusCode)
|
||||
}
|
||||
challenge := resp.Header.Get("WWW-Authenticate")
|
||||
want := `resource_metadata="http://localhost/.well-known/oauth-protected-resource/mcp"`
|
||||
if !strings.Contains(challenge, want) {
|
||||
t.Fatalf("WWW-Authenticate = %q, want it to contain %s", challenge, want)
|
||||
}
|
||||
|
||||
// 2. Discovery, both documents.
|
||||
var prm, asm map[string]any
|
||||
for path, into := range map[string]*map[string]any{
|
||||
"/.well-known/oauth-protected-resource/mcp": &prm,
|
||||
"/.well-known/oauth-authorization-server": &asm,
|
||||
} {
|
||||
r, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(into)
|
||||
r.Body.Close()
|
||||
if r.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s: status %d", path, r.StatusCode)
|
||||
}
|
||||
}
|
||||
if prm["resource"] != "http://localhost/mcp" {
|
||||
t.Errorf("resource = %v, want the address a person types in", prm["resource"])
|
||||
}
|
||||
if asm["issuer"] != "http://localhost" || asm["registration_endpoint"] != "http://localhost/oauth/register" {
|
||||
t.Errorf("authorization server metadata = %v", asm)
|
||||
}
|
||||
|
||||
// 3. Registration, 4. the browser's visit, 5. approval, 6. the exchange.
|
||||
clientID := registerClient(t, ts, claudeCallback)
|
||||
query := authorizeQuery(clientID, claudeCallback)
|
||||
|
||||
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
r, err := noFollow.Get(ts.URL + "/oauth/authorize?" + query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
if loc := r.Header.Get("Location"); r.StatusCode != http.StatusFound || !strings.HasPrefix(loc, "/forbind?") {
|
||||
t.Fatalf("authorize: status %d, Location %q — want the approval page", r.StatusCode, loc)
|
||||
}
|
||||
|
||||
// The approval page's question: what is this, and where does it go.
|
||||
info, body := ts.do(t, "GET", "/api/v1/oauth/request?"+query, nil)
|
||||
if info.StatusCode != http.StatusOK || body["client_name"] != "Claude" || body["redirect_to"] != "claude.ai" {
|
||||
t.Fatalf("request info: status %d, %v", info.StatusCode, body)
|
||||
}
|
||||
|
||||
code := approve(t, ts, query)
|
||||
tokResp, tok := exchange(t, ts, codeForm(clientID, code))
|
||||
if tokResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("token: status %d, %v", tokResp.StatusCode, tok)
|
||||
}
|
||||
access, _ := tok["access_token"].(string)
|
||||
if tok["token_type"] != "Bearer" || !strings.HasPrefix(access, "vrd_") {
|
||||
t.Fatalf("token response = %v", tok)
|
||||
}
|
||||
if cc := tokResp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q on a response carrying a token", cc)
|
||||
}
|
||||
|
||||
// 7. The token works where the person typed the address.
|
||||
if resp := mcpInitialize(t, ts, access); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("/mcp with the token: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 8. And it is on the tokens page, under the client's name, marked as such.
|
||||
_, list := ts.do(t, "GET", "/api/v1/tokens", nil)
|
||||
tokens, _ := list["tokens"].([]any)
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("tokens page lists %d, want 1: %v", len(tokens), list)
|
||||
}
|
||||
entry := tokens[0].(map[string]any)
|
||||
if entry["name"] != "Claude" || entry["connected"] != true {
|
||||
t.Errorf("token entry = %v", entry)
|
||||
}
|
||||
|
||||
// 9. Revoked there, the client is told to sign in again.
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/tokens/"+entry["id"].(string), nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("revoke: status %d", resp.StatusCode)
|
||||
}
|
||||
resp = mcpInitialize(t, ts, access)
|
||||
if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("WWW-Authenticate") == "" {
|
||||
t.Errorf("revoked token: status %d, WWW-Authenticate %q", resp.StatusCode, resp.Header.Get("WWW-Authenticate"))
|
||||
}
|
||||
}
|
||||
|
||||
// A code is good once. A second exchange — a replay, or a code that leaked — gets
|
||||
// nothing, and neither does a wrong verifier, the wrong client or the wrong
|
||||
// redirect: each is somebody other than the one the code was issued to.
|
||||
func TestOAuthCodeIsBoundAndSingleUse(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
clientID := registerClient(t, ts, claudeCallback)
|
||||
other := registerClient(t, ts, claudeCallback)
|
||||
query := authorizeQuery(clientID, claudeCallback)
|
||||
|
||||
cases := map[string]func(url.Values){
|
||||
"wrong verifier": func(f url.Values) { f.Set("code_verifier", strings.Repeat("w", 43)) },
|
||||
"no verifier": func(f url.Values) { f.Del("code_verifier") },
|
||||
"other client": func(f url.Values) { f.Set("client_id", other) },
|
||||
"other redirect": func(f url.Values) { f.Set("redirect_uri", "https://claude.ai/elsewhere") },
|
||||
}
|
||||
for name, spoil := range cases {
|
||||
code := approve(t, ts, query)
|
||||
form := codeForm(clientID, code)
|
||||
spoil(form)
|
||||
if resp, body := exchange(t, ts, form); resp.StatusCode != http.StatusBadRequest || body["error"] != "invalid_grant" {
|
||||
t.Errorf("%s: status %d, %v — want invalid_grant", name, resp.StatusCode, body)
|
||||
}
|
||||
// Spent by the failed attempt, so the rightful owner cannot use it either.
|
||||
if resp, _ := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s: the code survived a failed exchange", name)
|
||||
}
|
||||
}
|
||||
|
||||
code := approve(t, ts, query)
|
||||
if resp, _ := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first exchange: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, body := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("replayed code: status %d, %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Where a code may be sent is the whole security of the approval: a client
|
||||
// registers its addresses, and a request for any other is refused without
|
||||
// sending anything anywhere.
|
||||
func TestOAuthRedirectRules(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
for _, bad := range []string{
|
||||
"javascript:alert(1)",
|
||||
"data:text/html,hi",
|
||||
"http://evil.example/cb", // plain http off this machine
|
||||
"https://claude.ai/cb#frag",
|
||||
"not a uri",
|
||||
} {
|
||||
resp, body := postJSON(t, ts.URL+"/oauth/register",
|
||||
`{"client_name":"x","redirect_uris":[`+jsonString(bad)+`]}`)
|
||||
if resp.StatusCode != http.StatusBadRequest || body["error"] != "invalid_redirect_uri" {
|
||||
t.Errorf("registering %q: status %d, %v", bad, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
clientID := registerClient(t, ts, claudeCallback)
|
||||
noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
r, err := noFollow.Get(ts.URL + "/oauth/authorize?" + authorizeQuery(clientID, "https://evil.example/cb"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
if r.StatusCode != http.StatusBadRequest || r.Header.Get("Location") != "" {
|
||||
t.Errorf("unregistered redirect: status %d, Location %q — want a refusal shown here",
|
||||
r.StatusCode, r.Header.Get("Location"))
|
||||
}
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{
|
||||
"query": authorizeQuery(clientID, "https://evil.example/cb"), "approve": true,
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("approving an unregistered redirect: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// A desktop client listens on whatever port it is given that day.
|
||||
loopback := registerClient(t, ts, "http://127.0.0.1/callback")
|
||||
r, err = noFollow.Get(ts.URL + "/oauth/authorize?" + authorizeQuery(loopback, "http://127.0.0.1:53682/callback"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
if r.StatusCode != http.StatusFound || !strings.HasPrefix(r.Header.Get("Location"), "/forbind?") {
|
||||
t.Errorf("loopback with a port: status %d, Location %q", r.StatusCode, r.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// No PKCE is answered to the client — its address is trusted by now.
|
||||
q, _ := url.ParseQuery(authorizeQuery(clientID, claudeCallback))
|
||||
q.Del("code_challenge")
|
||||
r, err = noFollow.Get(ts.URL + "/oauth/authorize?" + q.Encode())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
if loc := r.Header.Get("Location"); !strings.HasPrefix(loc, claudeCallback+"?") || !strings.Contains(loc, "error=invalid_request") {
|
||||
t.Errorf("no PKCE: Location %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// An approval mints a token, so a token must not be able to approve: otherwise
|
||||
// one leaked token is a machine for making more that survive its revocation.
|
||||
func TestOAuthApprovalNeedsASession(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
var userID string
|
||||
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := ts.apiToken(t, userID)
|
||||
clientID := registerClient(t, ts, claudeCallback)
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/oauth/approve",
|
||||
strings.NewReader(`{"query":"`+authorizeQuery(clientID, claudeCallback)+`","approve":true}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("approval with an API token: status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// "Afvis" sends the client away with access_denied and no code.
|
||||
func TestOAuthDenial(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
clientID := registerClient(t, ts, claudeCallback)
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{
|
||||
"query": authorizeQuery(clientID, claudeCallback), "approve": false,
|
||||
})
|
||||
back, _ := url.Parse(body["redirect_to"].(string))
|
||||
if back.Query().Get("error") != "access_denied" || back.Query().Get("code") != "" {
|
||||
t.Errorf("denial redirect = %v", body["redirect_to"])
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting the same client again replaces its token rather than adding one.
|
||||
func TestOAuthReconnectReplacesTheToken(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
clientID := registerClient(t, ts, claudeCallback)
|
||||
query := authorizeQuery(clientID, claudeCallback)
|
||||
|
||||
_, first := exchange(t, ts, codeForm(clientID, approve(t, ts, query)))
|
||||
_, second := exchange(t, ts, codeForm(clientID, approve(t, ts, query)))
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/tokens", nil)
|
||||
if n := len(list["tokens"].([]any)); n != 1 {
|
||||
t.Errorf("tokens after reconnecting: %d, want 1", n)
|
||||
}
|
||||
if resp := mcpInitialize(t, ts, first["access_token"].(string)); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("the replaced token still works: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp := mcpInitialize(t, ts, second["access_token"].(string)); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("the new token does not work: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
@@ -45,11 +45,15 @@ func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
ticketed, _ := r.Context().Value(ctxTicket).(bool)
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// The socket is same-origin only. Without this, any page on the internet
|
||||
// could open a socket carrying the user's cookie and watch their tasks —
|
||||
// the same-origin policy does not apply to WebSocket handshakes.
|
||||
OriginPatterns: s.allowedOrigins(),
|
||||
// Except one opened with a ticket, which carries no cookie for a foreign
|
||||
// page to borrow: see handleWebSocketEntry.
|
||||
InsecureSkipVerify: ticketed,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Debug("websocket handshake", "err", err)
|
||||
|
||||
@@ -60,6 +60,14 @@ type Server struct {
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// One-use tickets for opening the live-sync socket without a cookie — see
|
||||
// app_handlers.go.
|
||||
wsTickets *wsTickets
|
||||
|
||||
// Authorization codes on their way to becoming an MCP client's token — see
|
||||
// oauth_handlers.go.
|
||||
oauthCodes *oauthCodes
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
@@ -69,6 +77,7 @@ type Server struct {
|
||||
mailLimiter *limiter
|
||||
hookLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
oauthLimiter *limiter
|
||||
// Second factors are counted against the *account*, not the address. An
|
||||
// address bucket is defeated by anybody with more than one address, which is
|
||||
// the ordinary case — and a six-digit code is only safe while guesses are
|
||||
@@ -111,10 +120,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
mailLimiter: newLimiter(60, time.Minute),
|
||||
hookLimiter: newLimiter(60, time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
// Registration and the token exchange are open to anybody who can reach
|
||||
// the instance. A real client does each once per connection; thirty an
|
||||
// hour is a ceiling nobody connecting meets.
|
||||
oauthLimiter: newLimiter(30, time.Hour),
|
||||
// Tighter than the password bucket, and for a whole hour. Somebody typing
|
||||
// their own code gets it right on the first or second try; ten in an hour is
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
wsTickets: newWSTickets(),
|
||||
oauthCodes: newOAuthCodes(),
|
||||
}
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
@@ -254,6 +269,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// nothing else.
|
||||
r.Post("/mcp", s.handleMCPWithKey)
|
||||
|
||||
// Signing in to MCP with the account: discovery, registration, approval and
|
||||
// the token exchange. In both editions, like the MCP server itself. None of
|
||||
// them reads a cookie — the approval does, and it lives in /api/v1 below,
|
||||
// behind the session and the CSRF check like every other write a browser
|
||||
// makes. See oauth_handlers.go.
|
||||
r.Get("/.well-known/oauth-protected-resource", s.handleProtectedResourceMetadata)
|
||||
r.Get("/.well-known/oauth-protected-resource/mcp", s.handleProtectedResourceMetadata)
|
||||
r.Get("/.well-known/oauth-authorization-server", s.handleAuthorizationServerMetadata)
|
||||
r.Get("/oauth/authorize", s.handleOAuthAuthorize)
|
||||
r.Options("/oauth/register", handleOpenPreflight)
|
||||
r.Options("/oauth/token", handleOpenPreflight)
|
||||
r.Method(http.MethodPost, "/oauth/register",
|
||||
s.rateLimit(s.oauthLimiter, http.HandlerFunc(s.handleOAuthRegister)))
|
||||
r.Method(http.MethodPost, "/oauth/token",
|
||||
s.rateLimit(s.oauthLimiter, http.HandlerFunc(s.handleOAuthToken)))
|
||||
|
||||
// Google sends the browser here after consent. Outside /api/v1 because it is a
|
||||
// top-level navigation rather than a fetch, but still behind the session — the
|
||||
// tokens it exchanges belong to whoever is signed in.
|
||||
@@ -273,11 +304,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
}
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Before the CSRF lock, so a preflight is answered and never reaches it.
|
||||
r.Use(s.appCORS)
|
||||
r.Use(s.requireCSRF)
|
||||
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.Get("/ping", s.handlePing)
|
||||
|
||||
// The live-sync socket checks its own credential: a ticket, or the
|
||||
// session as everywhere else. See handleWebSocketEntry.
|
||||
r.Get("/ws", s.handleWebSocketEntry)
|
||||
|
||||
// The beacon's receiving end, and the number it produces. Both without a
|
||||
// session, because the caller is a stranger's installation — that is the
|
||||
@@ -378,7 +413,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
full := !s.cfg.NotesOnly()
|
||||
r.Use(s.requireAuth)
|
||||
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
r.Post("/ws-ticket", s.handleWSTicket)
|
||||
|
||||
// The MCP endpoint. Reachable with a personal API token, so a connector
|
||||
// sees exactly what its owner sees.
|
||||
@@ -449,6 +484,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/{tokenID}", s.handleDeleteAPIToken)
|
||||
})
|
||||
|
||||
// The approval page's two questions — what am I approving, and yes or
|
||||
// no. Behind requireSession for the reason the tokens are: an approval
|
||||
// mints a token.
|
||||
r.Route("/oauth", func(r chi.Router) {
|
||||
r.Use(s.requireSession)
|
||||
r.Get("/request", s.handleOAuthRequest)
|
||||
r.Post("/approve", s.handleOAuthApprove)
|
||||
})
|
||||
|
||||
r.Route("/ai", func(r chi.Router) {
|
||||
r.Get("/settings", s.handleGetAISettings)
|
||||
r.Put("/settings", s.handleSetAISettings)
|
||||
@@ -796,10 +840,9 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// That is not hypothetical. Claude probes
|
||||
// /.well-known/oauth-authorization-server before connecting to an MCP server,
|
||||
// read 200 and a page of HTML as "there is an authorization server here", and
|
||||
// then failed trying to register with it — reporting a broken sign-in service
|
||||
// on a server that has none and needs none. A 404 means "no OAuth here", the
|
||||
// client stops asking, and the key in the URL is used as intended.
|
||||
// and before that path was a real route it read 200 and a page of HTML as
|
||||
// "there is an authorization server here" and failed trying to register with
|
||||
// it. The OAuth paths are routes now; everything else under here is a 404.
|
||||
if strings.HasPrefix(r.URL.Path, "/.well-known/") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
return
|
||||
|
||||
@@ -23,6 +23,9 @@ type apiTokenJSON struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
// Connected is true for a token an app received by signing in (OAuth),
|
||||
// rather than one made on this page.
|
||||
Connected bool `json:"connected,omitempty"`
|
||||
}
|
||||
|
||||
// createdTokenJSON is the only response that carries the token itself, and only
|
||||
@@ -45,6 +48,7 @@ func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
j := apiTokenJSON{
|
||||
ID: t.ID, Name: t.Name, Prefix: t.Prefix,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
Connected: t.Connected,
|
||||
}
|
||||
if t.LastUsedAt != nil {
|
||||
j.LastUsedAt = t.LastUsedAt.Format(time.RFC3339)
|
||||
|
||||
@@ -304,22 +304,28 @@ func extractPriority(input string, spans []Span) (int, []Span) {
|
||||
var (
|
||||
// An explicit marker: "kl 10", "kl. 10:30", "klokken 16", "at 9.15".
|
||||
reTimeMarked = regexp.MustCompile(`(?i)(?:^|\s)(kl\.?|klokken|at)\s*(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)?(?:\s|$)`)
|
||||
// A 12-hour suffix carries its own marker: "10am", "7:30 pm".
|
||||
reTimeMeridiem = regexp.MustCompile(`(?i)(?:^|\s)(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s|$)`)
|
||||
// A 12-hour suffix carries its own marker: "10am", "7:30 pm" — and may still
|
||||
// be written after one, "at 10am", "kl 7 pm". The marker is part of the time:
|
||||
// left behind, "tomorrow at 10am" became the title "… at".
|
||||
reTimeMeridiem = regexp.MustCompile(`(?i)(?:^|\s)((?:at|kl\.?|klokken)\s*)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s|$)`)
|
||||
// A bare colon time: "16:30". Only ':' — never '.', because "15.3" is a date.
|
||||
reTimeColon = regexp.MustCompile(`(?:^|\s)(\d{1,2}):(\d{2})(?:\s|$)`)
|
||||
)
|
||||
|
||||
func extractTime(input string, spans []Span, locale string) (int, int, bool, []Span) {
|
||||
if m := reTimeMeridiem.FindStringSubmatchIndex(input); m != nil {
|
||||
hour := atoi(input, m[2], m[3])
|
||||
hour := atoi(input, m[4], m[5])
|
||||
minute := 0
|
||||
if m[4] >= 0 {
|
||||
minute = atoi(input, m[4], m[5])
|
||||
if m[6] >= 0 {
|
||||
minute = atoi(input, m[6], m[7])
|
||||
}
|
||||
hour = applyMeridiem(hour, strings.ToLower(input[m[6]:m[7]]))
|
||||
hour = applyMeridiem(hour, strings.ToLower(input[m[8]:m[9]]))
|
||||
if valid(hour, minute) {
|
||||
return hour, minute, true, append(spans, Span{Start: m[2], End: m[7], Kind: KindTime})
|
||||
start := m[4]
|
||||
if m[2] >= 0 {
|
||||
start = m[2]
|
||||
}
|
||||
return hour, minute, true, append(spans, Span{Start: start, End: m[9], Kind: KindTime})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +435,37 @@ var (
|
||||
reNumeric = regexp.MustCompile(`(?:^|\s)(\d{1,2})[/.](\d{1,2})(?:[-/](\d{2,4}))?(?:\s|$)`)
|
||||
)
|
||||
|
||||
// fractionUnits are the words that make "3/4" a quantity rather than a day.
|
||||
//
|
||||
// "køb 1/2 liter fløde" is half a litre, not the first of February — and it is
|
||||
// the ordinary shape of a shopping list or a recipe. Without a unit after it the
|
||||
// slash form stays a date: "3/4" alone, "møde 3/4 med Anders", and also
|
||||
// "3/4 kage", because nothing in "kage" tells the third of April from three
|
||||
// quarters, and a date quietly dropped is worse than one somebody removes.
|
||||
var fractionUnits = map[string]bool{
|
||||
"l": true, "liter": true, "dl": true, "cl": true, "ml": true,
|
||||
"kg": true, "g": true, "gram": true, "pund": true,
|
||||
"tsk": true, "spsk": true, "kop": true, "kopper": true, "glas": true,
|
||||
"stk": true, "stk.": true, "del": true, "dele": true, "pakke": true, "pakker": true,
|
||||
"time": true, "timer": true, "km": true, "m": true, "cm": true,
|
||||
"cup": true, "cups": true, "tsp": true, "tbsp": true, "lb": true, "oz": true,
|
||||
}
|
||||
|
||||
// firstNumericDate is the first "d/m" in the input that is not a quantity — see
|
||||
// fractionUnits. A written year always makes it a date.
|
||||
func firstNumericDate(input string) []int {
|
||||
for _, m := range reNumeric.FindAllStringSubmatchIndex(input, -1) {
|
||||
if m[6] < 0 {
|
||||
rest := strings.Fields(input[m[5]:])
|
||||
if len(rest) > 0 && fractionUnits[strings.ToLower(rest[0])] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractDate(input string, spans []Span, now time.Time) (time.Time, bool, []Span) {
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
|
||||
@@ -441,7 +478,7 @@ func extractDate(input string, spans []Span, now time.Time) (time.Time, bool, []
|
||||
}
|
||||
}
|
||||
|
||||
if m := reNumeric.FindStringSubmatchIndex(input); m != nil {
|
||||
if m := firstNumericDate(input); m != nil {
|
||||
day, mo := atoi(input, m[2], m[3]), atoi(input, m[4], m[5])
|
||||
year := today.Year()
|
||||
explicitYear := m[6] >= 0
|
||||
|
||||
@@ -784,3 +784,50 @@ func TestImpossibleReminderClockIsNotKept(t *testing.T) {
|
||||
t.Errorf("ReminderOffsetMin = %d, want none", *got.ReminderOffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
// A marker written before a 12-hour time belongs to the time. Left behind, it
|
||||
// became the end of the title: "file the VAT return tomorrow at 10am" was the
|
||||
// task "file the VAT return at".
|
||||
func TestMarkerBeforeAMeridiemTimeIsConsumed(t *testing.T) {
|
||||
now := ref(t)
|
||||
for _, tc := range []struct{ in, content, due, time string }{
|
||||
{"file the VAT return tomorrow at 10am", "file the VAT return", "2026-03-11", "10:00"},
|
||||
{"call at 7:30 pm", "call", "2026-03-10", "19:30"},
|
||||
{"call the dentist tomorrow at 9am remind me an hour before", "call the dentist", "2026-03-11", "09:00"},
|
||||
{"møde i morgen kl 7 pm", "møde", "2026-03-11", "19:00"},
|
||||
// Control: a meridiem time with no marker still works, and "at" that is
|
||||
// not in front of a time is a word.
|
||||
{"standup tomorrow 9am", "standup", "2026-03-11", "09:00"},
|
||||
{"look at the report tomorrow", "look at the report", "2026-03-11", ""},
|
||||
} {
|
||||
got := Parse(tc.in, now, "en")
|
||||
if got.Content != tc.content || got.DueDate != tc.due || got.DueTime != tc.time {
|
||||
t.Errorf("%q → %q %s %s, want %q %s %s", tc.in, got.Content, got.DueDate, got.DueTime,
|
||||
tc.content, tc.due, tc.time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "1/2 liter" is half a litre. A day/month followed by a unit is a quantity and
|
||||
// stays in the title; without a unit it is still a date, because "3/4" alone and
|
||||
// "møde 3/4 med Anders" are the third of April.
|
||||
func TestAFractionWithAUnitIsNotADate(t *testing.T) {
|
||||
now := ref(t)
|
||||
for _, tc := range []struct{ in, content, due string }{
|
||||
{"køb 1/2 liter fløde", "køb 1/2 liter fløde", ""},
|
||||
{"tilsæt 3/4 dl mælk", "tilsæt 3/4 dl mælk", ""},
|
||||
{"bag med 1/2 kg mel i morgen", "bag med 1/2 kg mel", "2026-03-11"},
|
||||
// A quantity first, a real date after it: the date is still found.
|
||||
{"køb 1/2 liter fløde 15/3", "køb 1/2 liter fløde", "2026-03-15"},
|
||||
// Controls: no unit, or a year — a date.
|
||||
{"møde 3/4", "møde", "2026-04-03"},
|
||||
{"møde 3/4 med Anders", "møde med Anders", "2026-04-03"},
|
||||
{"halvdelen 3/4 kage", "halvdelen kage", "2026-04-03"},
|
||||
{"frist 1/2-2027 liter", "frist liter", "2027-02-01"},
|
||||
} {
|
||||
got := Parse(tc.in, now, "da")
|
||||
if got.Content != tc.content || got.DueDate != tc.due {
|
||||
t.Errorf("%q → %q %q, want %q %q", tc.in, got.Content, got.DueDate, tc.content, tc.due)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ type APIToken struct {
|
||||
CreatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
ExpiresAt *time.Time
|
||||
// Connected marks a token an OAuth approval minted rather than one the
|
||||
// person made by hand — it carries the client's name, not one they chose.
|
||||
Connected bool
|
||||
}
|
||||
|
||||
// CreateAPIToken returns the token in plaintext exactly once. Only its hash is
|
||||
@@ -80,7 +83,8 @@ func (db *DB) UserByAPIToken(ctx context.Context, token string) (*User, error) {
|
||||
|
||||
func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at
|
||||
`SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at,
|
||||
oauth_client_id IS NOT NULL
|
||||
FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -92,7 +96,7 @@ func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, err
|
||||
var t APIToken
|
||||
var created int64
|
||||
var lastUsed, expires sql.NullInt64
|
||||
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires, &t.Connected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- At forbinde Claude med sin konto i stedet for med et token, man selv har lavet.
|
||||
--
|
||||
-- Claude's forbindelsesdialog prøver OAuth, før den gør noget andet, og indtil nu
|
||||
-- var svaret "det findes ikke her" — så den eneste vej ind var at lave et token i
|
||||
-- indstillingerne og sætte det ind i en adresse. Det virker, men det er en
|
||||
-- hemmelighed, man skal kopiere mellem to programmer, og det er præcis det trin,
|
||||
-- folk går i stå på.
|
||||
--
|
||||
-- En klient registrerer sig selv (RFC 7591) og får et id. Ingen hemmelighed: en
|
||||
-- klient, der kører hos en anden, kan ikke holde på en, så det er PKCE, der beviser,
|
||||
-- at det er den samme, der kommer tilbage med koden.
|
||||
CREATE TABLE oauth_clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
-- JSON-liste. Kun adresser, klienten selv opgav ved registreringen, kan få en
|
||||
-- kode sendt hen til sig — ellers er "godkend" en knap, der sender adgangen til
|
||||
-- hvem som helst.
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Det, en godkendelse udsteder, er et almindeligt API-token. Det står derfor på
|
||||
-- siden med tokens, kan tilbagekaldes der, og når alle de steder, et token
|
||||
-- allerede gælder. Kolonnen siger, hvilken klient det hører til, så en ny
|
||||
-- godkendelse fra den samme klient erstatter den gamle frem for at lægge sig ved
|
||||
-- siden af.
|
||||
ALTER TABLE api_tokens ADD COLUMN oauth_client_id TEXT REFERENCES oauth_clients (id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_api_tokens_oauth_client ON api_tokens (oauth_client_id) WHERE oauth_client_id IS NOT NULL;
|
||||
@@ -0,0 +1,99 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
)
|
||||
|
||||
// OAuthClient is a program that registered itself to connect to an account: in
|
||||
// practice an MCP client such as Claude. It holds no secret — see migration 0038.
|
||||
type OAuthClient struct {
|
||||
ID string
|
||||
Name string
|
||||
RedirectURIs []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// unusedClientAge is how long a registration may sit without ever being
|
||||
// approved before it is cleared. Registration is open to anybody who can reach
|
||||
// the instance, so without this the table grows with every abandoned attempt.
|
||||
const unusedClientAge = 24 * time.Hour
|
||||
|
||||
func (db *DB) CreateOAuthClient(ctx context.Context, name string, redirectURIs []string) (*OAuthClient, error) {
|
||||
uris, err := json.Marshal(redirectURIs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &OAuthClient{ID: NewID(), Name: name, RedirectURIs: redirectURIs, CreatedAt: time.Now().UTC()}
|
||||
|
||||
// Swept on the way in, like the socket tickets: the table is bounded by a
|
||||
// day of asking, and nothing has to remember to run a job for it.
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`DELETE FROM oauth_clients WHERE created_at < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM api_tokens WHERE oauth_client_id = oauth_clients.id)`,
|
||||
time.Now().Add(-unusedClientAge).Unix())
|
||||
|
||||
_, err = db.ExecContext(ctx,
|
||||
`INSERT INTO oauth_clients (id, name, redirect_uris, created_at) VALUES (?, ?, ?, ?)`,
|
||||
c.ID, c.Name, string(uris), c.CreatedAt.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (db *DB) OAuthClient(ctx context.Context, id string) (*OAuthClient, error) {
|
||||
var c OAuthClient
|
||||
var uris string
|
||||
var created int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, name, redirect_uris, created_at FROM oauth_clients WHERE id = ?`, id).
|
||||
Scan(&c.ID, &c.Name, &uris, &created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(uris), &c.RedirectURIs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = time.Unix(created, 0).UTC()
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateOAuthToken mints the API token an approval hands to a client.
|
||||
//
|
||||
// Any earlier token this person gave the same client is deleted in the same
|
||||
// transaction. A client that connects again — after a reinstall, or because
|
||||
// somebody pressed "connect" twice — then has one entry on the tokens page
|
||||
// rather than a growing column of identical ones, each still valid.
|
||||
func (db *DB) CreateOAuthToken(ctx context.Context, userID string, client *OAuthClient) (string, error) {
|
||||
token, prefix, err := auth.NewAPIToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM api_tokens WHERE user_id = ? AND oauth_client_id = ?`, userID, client.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, oauth_client_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
NewID(), userID, client.Name, auth.HashToken(token), prefix, time.Now().Unix(), client.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, tx.Commit()
|
||||
}
|
||||
@@ -56,6 +56,7 @@ nav:
|
||||
- Opening links: links.md
|
||||
- Filters: filters.md
|
||||
- Importing from Todoist: import.md
|
||||
- The iOS app: ios.md
|
||||
- Connecting things:
|
||||
- Calendar: calendar.md
|
||||
- Feeds and CalDAV: caldav.md
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USER } from './user.js';
|
||||
|
||||
/**
|
||||
* The interface the way the iOS app runs it: from a different origin than the
|
||||
* server, with no cookie, its session as a bearer token.
|
||||
*
|
||||
* The real app is served from capacitor://localhost, which a test browser cannot
|
||||
* open. `app.localhost` stands in for it — Chromium sends every *.localhost to
|
||||
* the loopback address, so it is this same server, but to the browser it is a
|
||||
* different origin, and that difference is the whole thing under test. The server
|
||||
* is told to treat it as the app's origin (VERDANDE_APP_ORIGINS in the config),
|
||||
* and a stub of Capacitor's bridge makes the interface believe it is in the app.
|
||||
*
|
||||
* bypassCSP because the server's policy is written for its own pages
|
||||
* (`connect-src 'self'`); the app's pages are inside the app and never carry it.
|
||||
* Without this the browser would block exactly the cross-origin calls the test
|
||||
* exists to make, and the failure would read as the app not working.
|
||||
*/
|
||||
|
||||
const PORT = 8097;
|
||||
const SERVER = `http://localhost:${PORT}`;
|
||||
|
||||
test.use({
|
||||
baseURL: `http://app.localhost:${PORT}`,
|
||||
bypassCSP: true,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.Capacitor = { isNativePlatform: () => true };
|
||||
});
|
||||
});
|
||||
|
||||
/** The app's session, read the way the app reads it. */
|
||||
const storedToken = (page) =>
|
||||
page.evaluate(() => localStorage.getItem('verdande.app.session') ?? '');
|
||||
|
||||
test('appen vælger sin server, logger ind med et token og følger med live', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// First the address — and an address with nothing behind it is said to be so,
|
||||
// rather than leading to a sign-in form that cannot succeed.
|
||||
await expect(page.getByText('Hvilken Verdande vil du logge ind på?')).toBeVisible();
|
||||
await page.getByLabel(/Serveradresse/).fill('http://localhost:1');
|
||||
await page.getByRole('button', { name: 'Fortsæt' }).click();
|
||||
await expect(page.getByText(/Kunne ikke nå localhost:1/)).toBeVisible();
|
||||
|
||||
await page.getByLabel(/Serveradresse/).fill(SERVER);
|
||||
await page.getByRole('button', { name: 'Fortsæt' }).click();
|
||||
|
||||
// The sign-in form, saying where it signs in to.
|
||||
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
// Signed in by token, and by nothing else: no session cookie on either origin.
|
||||
const token = await storedToken(page);
|
||||
expect(token).not.toBe('');
|
||||
const cookies = await page.context().cookies();
|
||||
expect(cookies.filter((c) => c.name.includes('verdande_session'))).toEqual([]);
|
||||
|
||||
// Live sync over a ticketed socket: a project made behind the interface's back
|
||||
// — straight to the API, as another device would — shows up in the sidebar
|
||||
// without a reload. Nothing but the socket can have put it there.
|
||||
const name = `Fra en anden enhed ${Date.now()}`;
|
||||
const status = await page.evaluate(
|
||||
async ({ server, token, name }) => {
|
||||
const res = await fetch(`${server}/api/v1/projects`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
return res.status;
|
||||
},
|
||||
{ server: SERVER, token, name }
|
||||
);
|
||||
expect(status).toBe(201);
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' }).getByText(name)).toBeVisible();
|
||||
|
||||
// A picture from the server, which an <img> cannot fetch with a token. It is
|
||||
// shown from a blob, and the path it came from is kept for the note to save.
|
||||
const path = await page.evaluate(
|
||||
async ({ server, token }) => {
|
||||
const auth = { Authorization: `Bearer ${token}` };
|
||||
const note = await fetch(`${server}/api/v1/notes`, {
|
||||
method: 'POST',
|
||||
headers: { ...auth, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: '# Billede fra appen' })
|
||||
}).then((r) => r.json());
|
||||
// A one-pixel PNG.
|
||||
const png = Uint8Array.from(
|
||||
atob(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=='
|
||||
),
|
||||
(c) => c.charCodeAt(0)
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
|
||||
const saved = await fetch(`${server}/api/v1/notes/${note.id}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: auth,
|
||||
body: form
|
||||
}).then((r) => r.json());
|
||||
const img = document.createElement('img');
|
||||
img.id = 'appbillede';
|
||||
img.setAttribute('src', saved.url);
|
||||
document.body.append(img);
|
||||
return saved.url;
|
||||
},
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
const img = page.locator('#appbillede');
|
||||
await expect(img).toHaveAttribute('data-src', path);
|
||||
await expect(img).toHaveAttribute('src', /^blob:/);
|
||||
await expect.poll(() => img.evaluate((el) => el.naturalWidth)).toBe(1);
|
||||
|
||||
// Signing out takes the token off the phone, and keeps the server.
|
||||
await page.evaluate(() => document.getElementById('appbillede')?.remove());
|
||||
// Two taps: the first asks, the second signs out.
|
||||
await page.getByRole('button', { name: /Log ud/ }).click();
|
||||
await page.getByRole('button', { name: /Tryk igen for at logge ud/ }).click();
|
||||
await expect(page.getByText(`localhost:${PORT}`)).toBeVisible();
|
||||
expect(await storedToken(page)).toBe('');
|
||||
|
||||
// And the token is dead on the server too, not just forgotten here.
|
||||
const after = await page.evaluate(
|
||||
async ({ server, token }) =>
|
||||
(await fetch(`${server}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } }))
|
||||
.status,
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
expect(after).toBe(401);
|
||||
});
|
||||
|
||||
/**
|
||||
* Signs in the way the app does, outside the page, and hands the page the server
|
||||
* and the session as the app would have stored them.
|
||||
*/
|
||||
async function signedInApp(page) {
|
||||
const origin = { Origin: `http://app.localhost:${PORT}` };
|
||||
const res = await fetch(`${SERVER}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { ...origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: USER.email,
|
||||
password: USER.password,
|
||||
client: 'app',
|
||||
device_name: 'Prøve-iPhone'
|
||||
})
|
||||
});
|
||||
const { session_token: token } = await res.json();
|
||||
expect(token).toBeTruthy();
|
||||
await page.addInitScript(
|
||||
({ server, token }) => {
|
||||
localStorage.setItem('verdande.app.server', server);
|
||||
localStorage.setItem('verdande.app.session', token);
|
||||
},
|
||||
{ server: SERVER, token }
|
||||
);
|
||||
const call = async (method, path, body) => {
|
||||
const init = { method, headers: { ...origin, Authorization: `Bearer ${token}` } };
|
||||
if (body instanceof FormData) init.body = body;
|
||||
else if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(`${SERVER}/api/v1${path}`, init);
|
||||
return r.status === 204 ? null : r.json();
|
||||
};
|
||||
return call;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture in a note is shown from a blob made on the phone. Editing the note
|
||||
* turns the page back into markdown, and what it must write is the path the
|
||||
* picture came from — a blob address saved into a note is a picture that is gone
|
||||
* the next time anybody opens it, on any device.
|
||||
*/
|
||||
test('en note med et billede gemmer stien, ikke blob-adressen, når den redigeres i appen', async ({
|
||||
page
|
||||
}) => {
|
||||
const call = await signedInApp(page);
|
||||
|
||||
const note = await call('POST', '/notes', { body: '# Billednote fra appen' });
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png');
|
||||
const saved = await call('POST', `/notes/${note.id}/attachments`, form);
|
||||
await call('PATCH', `/notes/${note.id}`, {
|
||||
body: `# Billednote fra appen\n\n\n\nTekst under billedet`
|
||||
});
|
||||
|
||||
await page.goto('/noter?note=' + note.id);
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
const img = ed.locator('img');
|
||||
await expect(img).toHaveAttribute('src', /^blob:/);
|
||||
await expect(img).toHaveAttribute('data-src', saved.url);
|
||||
|
||||
// Touch the note so it saves, and wait for the save itself.
|
||||
const patched = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PATCH' && /\/notes\/[^/]+$/.test(new URL(r.url()).pathname) && r.ok()
|
||||
);
|
||||
await ed.getByText('Tekst under billedet').click();
|
||||
await page.keyboard.press('End');
|
||||
await page.keyboard.type(' og mere');
|
||||
await patched;
|
||||
|
||||
const after = await call('GET', `/notes/${note.id}`);
|
||||
expect(after.body).toContain('og mere');
|
||||
expect(after.body).toContain(`](${saved.url})`);
|
||||
expect(after.body).not.toContain('blob:');
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { USER } from './user.js';
|
||||
import { watchForTrouble } from './trouble.js';
|
||||
|
||||
@@ -6089,3 +6090,157 @@ test('knappen findes ikke i urd-ansigtet', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible();
|
||||
});
|
||||
|
||||
/** Calls the API as the signed-in page does, and returns the parsed body. */
|
||||
async function apiCall(page, method, path, body) {
|
||||
return page.evaluate(
|
||||
async ({ method, path, body }) => {
|
||||
const init = { method, credentials: 'include', headers: { 'Sec-Fetch-Site': 'same-origin' } };
|
||||
if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch('/api/v1' + path, init);
|
||||
return r.status === 204 ? null : r.json();
|
||||
},
|
||||
{ method, path, body }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Et "#navn", der ikke findes, er klippet ud af titlen af parseren og lagt et
|
||||
* andet sted af serveren. Uden en besked forsvandt ordet bare.
|
||||
*/
|
||||
test('et ukendt projekt i hurtig tilføjelse bliver sagt højt', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const name = `Findesikke${Date.now()}`;
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill(`køb mælk i dag #${name}`);
|
||||
await box.press('Enter');
|
||||
|
||||
await expect(page.locator('.toast').filter({ hasText: name })).toContainText(
|
||||
'Der er intet projekt'
|
||||
);
|
||||
await expect(page.locator('.toast').filter({ hasText: name })).toContainText('Indbakke');
|
||||
});
|
||||
|
||||
/**
|
||||
* At skrive i en opgaves titel læses som hurtig tilføjelse læser: dato,
|
||||
* klokkeslæt, gentagelse, projekt og alarm. Gentagelsen blev engang læst under
|
||||
* et felt, der ikke findes, og forsvandt.
|
||||
*/
|
||||
test('en titel, der rettes i opgaveruden, tager gentagelse, tid, projekt og alarm med', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
const project = await apiCall(page, 'POST', '/projects', { name: `Titelprojekt ${Date.now()}` });
|
||||
const task = await apiCall(page, 'POST', '/tasks', { content: 'omskriv titel' });
|
||||
|
||||
await page.goto('/opgave/' + task.id);
|
||||
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
||||
const title = drawer.getByLabel('Opgavens tekst');
|
||||
await expect(title).toHaveValue('omskriv titel');
|
||||
|
||||
const saved = opgaveGemt(page);
|
||||
await title.fill(`omskriv titel hver mandag kl 9 #"${project.name}" mind mig en time før`);
|
||||
await title.press('Enter');
|
||||
await saved;
|
||||
|
||||
await expect(title).toHaveValue('omskriv titel');
|
||||
await expect(page.locator('.toast').filter({ hasText: 'Læst som' })).toBeVisible();
|
||||
|
||||
// Målt på serveren, ikke på skærmen: det er dér, det skal stå.
|
||||
await expect
|
||||
.poll(async () => (await apiCall(page, 'GET', '/tasks/' + task.id)).recurrence_rule)
|
||||
.toBe('FREQ=WEEKLY;BYDAY=MO');
|
||||
const after = await apiCall(page, 'GET', '/tasks/' + task.id);
|
||||
expect(after.content).toBe('omskriv titel');
|
||||
expect(after.project_id).toBe(project.id);
|
||||
// I kontoens zone, ikke i den proces, prøven kører i: en CI-runner i UTC læser
|
||||
// 09:00 i København som 08.
|
||||
const local = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/Copenhagen',
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).format(new Date(after.due_datetime));
|
||||
expect(local).toBe('Mon 09:00');
|
||||
const { reminders } = await apiCall(page, 'GET', `/tasks/${task.id}/reminders`);
|
||||
expect(reminders.map((r) => r.offset_min)).toEqual([-60]);
|
||||
});
|
||||
|
||||
/**
|
||||
* At forbinde Claude med kontoen: hele dansen, som Claude danser den, med den
|
||||
* eneste del, en Go-prøve ikke kan se — at godkendelsessiden findes, logger én
|
||||
* ind, når det trænges, og at "Tillad" faktisk sender browseren tilbage med en
|
||||
* kode, der kan veksles til et token, som /mcp tager imod.
|
||||
*
|
||||
* Tilbageadressen er en fremmed vært, som prøven selv svarer for. En rigtig
|
||||
* claude.ai ville gøre prøven afhængig af nettet og af en side, vi ikke ejer.
|
||||
*/
|
||||
test('Claude kan forbinde med kontoen i stedet for med et token', async ({ page, browser }) => {
|
||||
const callback = 'https://claude.example/api/mcp/auth_callback';
|
||||
const registered = await page.request.post('/oauth/register', {
|
||||
data: { client_name: 'Claude', redirect_uris: [callback], token_endpoint_auth_method: 'none' }
|
||||
});
|
||||
expect(registered.status()).toBe(201);
|
||||
const { client_id } = await registered.json();
|
||||
|
||||
// S256 af en fast nøgle. Regnet i Node: en tom side er ikke en sikker
|
||||
// kontekst, så browserens crypto.subtle findes ikke der.
|
||||
const verifier = 'v'.repeat(43);
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
|
||||
const query = new URLSearchParams({
|
||||
response_type: 'code', client_id, redirect_uri: callback, state: 'tilstand',
|
||||
code_challenge: challenge, code_challenge_method: 'S256'
|
||||
}).toString();
|
||||
|
||||
// Uden en session: login-skærmen, på godkendelsessidens adresse, så man
|
||||
// lander tilbage på spørgsmålet bagefter.
|
||||
// Tom storageState: browser.newContext() i en prøve arver projektets, og
|
||||
// projektets er en logget-ind konto.
|
||||
const stranger = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const signedOut = await stranger.newPage();
|
||||
await signedOut.goto('/oauth/authorize?' + query);
|
||||
await expect(signedOut.getByLabel('E-mail')).toBeVisible();
|
||||
expect(new URL(signedOut.url()).pathname).toBe('/forbind');
|
||||
await stranger.close();
|
||||
|
||||
let returned = null;
|
||||
await page.route('https://claude.example/**', (route) => {
|
||||
returned = new URL(route.request().url());
|
||||
return route.fulfill({ status: 200, contentType: 'text/plain', body: 'ok' });
|
||||
});
|
||||
|
||||
await page.goto('/oauth/authorize?' + query);
|
||||
await expect(page.getByRole('heading', { name: 'Claude vil forbinde til din konto' })).toBeVisible();
|
||||
// Hvor koden sendes hen er det, en klient ikke kan lyve om — det skal stå der.
|
||||
await expect(page.getByText('claude.example', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Tillad' }).click();
|
||||
await expect.poll(() => returned?.searchParams.get('code') ?? null).not.toBeNull();
|
||||
expect(returned.searchParams.get('state')).toBe('tilstand');
|
||||
|
||||
const exchanged = await page.request.post('/oauth/token', {
|
||||
form: {
|
||||
grant_type: 'authorization_code', code: returned.searchParams.get('code'),
|
||||
client_id, redirect_uri: callback, code_verifier: verifier
|
||||
}
|
||||
});
|
||||
expect(exchanged.status()).toBe(200);
|
||||
const { access_token } = await exchanged.json();
|
||||
expect(access_token).toMatch(/^vrd_/);
|
||||
|
||||
const mcp = await page.request.post('/mcp', {
|
||||
headers: { Authorization: 'Bearer ' + access_token },
|
||||
data: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }
|
||||
});
|
||||
expect(mcp.status()).toBe(200);
|
||||
expect((await mcp.json()).result.protocolVersion).toBeTruthy();
|
||||
|
||||
// Og den står under Tokens, hvor den kan tilbagekaldes.
|
||||
await page.goto('/indstillinger/tokens');
|
||||
await expect(page.getByText('forbundet med din konto')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -81,7 +81,8 @@ export default defineConfig({
|
||||
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
|
||||
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
|
||||
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
|
||||
testIgnore: /edition\.spec\.js/,
|
||||
// Og app-prøven, der kører fladen fra en anden origin med sin egen login.
|
||||
testIgnore: /(edition|app)\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
@@ -120,6 +121,19 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
|
||||
},
|
||||
|
||||
/**
|
||||
* Fladen, som iOS-appen kører den: fra en anden origin end serveren, uden
|
||||
* cookie, med sessionen som bearer. Se app.spec.js. Afhænger af `setup`,
|
||||
* fordi den logger ind på kontoen, opsætningen lavede — men låner ikke dens
|
||||
* cookie, for det er netop det, appen ikke har.
|
||||
*/
|
||||
{
|
||||
name: 'app-origin',
|
||||
testMatch: /app\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit-forms',
|
||||
grep: /@forms/,
|
||||
@@ -164,7 +178,10 @@ export default defineConfig({
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
VERDANDE_UPDATE_CHECK: 'false',
|
||||
// Den origin, app-origin-projektet kører fladen fra, regnet som appens
|
||||
// egen — som capacitor://localhost er det i den rigtige app.
|
||||
VERDANDE_APP_ORIGINS: `http://app.localhost:${PORT}`
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Renders the iOS app's icon and launch image from static/icon.svg.
|
||||
*
|
||||
* node scripts/generate-ios-assets.js
|
||||
*
|
||||
* Same reasoning as generate-icons.js: a script, not a test, run when the icon
|
||||
* changes, with Chromium as the SVG renderer because it is already here.
|
||||
*
|
||||
* Two things differ from the web icons, and both are Apple's rules rather than
|
||||
* taste. The app icon is square with no transparency — iOS draws the rounded
|
||||
* corners itself, and rounded corners already in the image show as a dark ring
|
||||
* inside its own mask — so the tile's `rx` is dropped and the result flattened.
|
||||
* And the launch image is the rune small on the tile colour, the size
|
||||
* Capacitor's template asks for, so the app opens on the colour it is about to
|
||||
* draw instead of on white.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const ICONS = '../app/ios/App/App/Assets.xcassets';
|
||||
const svg = readFileSync('static/icon.svg', 'utf8').replace(/ rx="[^"]*"/, '');
|
||||
const tile = /<rect[^>]*fill="(#[0-9a-fA-F]{6})"/.exec(svg)?.[1];
|
||||
if (!tile) throw new Error('static/icon.svg has no tile colour to paint the launch image with');
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
async function render(file, size, markSize) {
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0;background:${tile}}` +
|
||||
`div{width:${size}px;height:${size}px;display:grid;place-items:center}` +
|
||||
`svg{display:block;width:${markSize}px;height:${markSize}px}</style><div>${svg}</div>`
|
||||
);
|
||||
writeFileSync(file, await page.locator('div').screenshot({ omitBackground: false }));
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
}
|
||||
|
||||
await render(`${ICONS}/AppIcon.appiconset/AppIcon-512@2x.png`, 1024, 1024);
|
||||
// Capacitor's template names three files for the one launch image; all the same.
|
||||
for (const name of ['splash-2732x2732.png', 'splash-2732x2732-1.png', 'splash-2732x2732-2.png']) {
|
||||
await render(`${ICONS}/Splash.imageset/${name}`, 2732, 640);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
+39
-6
@@ -24,6 +24,7 @@ export class ApiError extends Error {
|
||||
|
||||
import { t } from './i18n.svelte.js';
|
||||
import { connectivity, sleepOrOnline } from './connectivity.svelte.js';
|
||||
import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js';
|
||||
|
||||
/**
|
||||
* How long a request keeps retrying a network failure before giving up.
|
||||
@@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) {
|
||||
const init = {
|
||||
method,
|
||||
headers: {},
|
||||
// The session cookie is httpOnly and same-origin; nothing here handles a token.
|
||||
credentials: 'same-origin',
|
||||
signal: options.signal
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
// In a browser: the httpOnly session cookie, same-origin, and nothing here
|
||||
// handles a token. In the iOS app: its session token as a bearer. See
|
||||
// platform.js.
|
||||
authorize(init);
|
||||
|
||||
// Retries a network failure for a few seconds so a short drop does not lose the
|
||||
// call; `withRetry` turns an exhausted window into the same `offline` error the
|
||||
// app already handles. `options.noRetry` opts a call out — used by nothing yet.
|
||||
const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry);
|
||||
const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry);
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
@@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') {
|
||||
form.append(field, file);
|
||||
|
||||
const res = await withRetry(() =>
|
||||
fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' })
|
||||
fetch(apiURL(path), authorize({ method: 'POST', body: form }))
|
||||
);
|
||||
|
||||
const text = await res.text();
|
||||
@@ -232,9 +235,30 @@ export const api = {
|
||||
// --- auth
|
||||
setupState: () => get('/auth/setup'),
|
||||
setup: (data) => post('/auth/setup', data),
|
||||
login: (email, password) => post('/auth/login', { email, password }),
|
||||
login: async (email, password) => {
|
||||
if (!isNativeApp()) return post('/auth/login', { email, password });
|
||||
// The app gets its session in the answer instead of a cookie, and keeps
|
||||
// it — also while a code is still owed, because the second step is sent
|
||||
// with it.
|
||||
const result = await post('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
client: 'app',
|
||||
device_name: deviceName()
|
||||
});
|
||||
if (result?.session_token) appSession.setToken(result.session_token);
|
||||
return result;
|
||||
},
|
||||
loginTOTP: (code) => post('/auth/login/totp', { code }),
|
||||
logout: () => post('/auth/logout'),
|
||||
logout: async () => {
|
||||
try {
|
||||
return await post('/auth/logout');
|
||||
} finally {
|
||||
// Gone from the phone whether or not the server heard: a sign-out that
|
||||
// left the token behind because the network was down is not one.
|
||||
if (isNativeApp()) appSession.signOut();
|
||||
}
|
||||
},
|
||||
me: () => get('/auth/me'),
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
@@ -421,6 +445,15 @@ export const api = {
|
||||
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
|
||||
deleteToken: (id) => del(`/tokens/${id}`),
|
||||
|
||||
// --- signing an MCP client in with the account (routes/forbind)
|
||||
// The query goes over unchanged: the server reads it with the same function
|
||||
// that checked it on the way in, so the two cannot disagree about it.
|
||||
oauthRequest: (query) => get(`/oauth/request${query}`),
|
||||
oauthApprove: (query, approve) => post('/oauth/approve', { query, approve }),
|
||||
|
||||
// --- live sync, for the app (see stores.svelte.js)
|
||||
wsTicket: () => post('/ws-ticket'),
|
||||
|
||||
// --- notifications and push
|
||||
listNotifications: () => get('/notifications'),
|
||||
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
|
||||
|
||||
@@ -21,9 +21,56 @@
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { available, signIn } from '$lib/passkey.js';
|
||||
import { appSession, isNativeApp, normaliseServer } from '$lib/platform.js';
|
||||
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
// loading | server | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
|
||||
// --- which server (the iOS app only) ------------------------------------------
|
||||
|
||||
const native = isNativeApp();
|
||||
let server = $state(native ? appSession.server() : '');
|
||||
let serverHost = $derived(server ? server.replace(/^https?:\/\//, '') : '');
|
||||
|
||||
/**
|
||||
* The app has no server of its own. Before a password field is shown for an
|
||||
* address somebody typed, the address has to be a Verdande: asked with /ping,
|
||||
* which answers without a login and names the product. A router's login page,
|
||||
* a typo that lands on somebody's blog, a server that is down — each gets its
|
||||
* own sentence here instead of a sign-in form that cannot succeed.
|
||||
*/
|
||||
async function chooseServer() {
|
||||
const url = normaliseServer(server);
|
||||
if (!url) {
|
||||
error = t('app.serverInvalid');
|
||||
return;
|
||||
}
|
||||
let answer;
|
||||
try {
|
||||
const res = await fetch(`${url}/api/v1/ping`, { credentials: 'omit' });
|
||||
answer = res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
error = t('app.serverUnreachable', { host: url.replace(/^https?:\/\//, '') });
|
||||
return;
|
||||
}
|
||||
if (answer?.product !== 'verdande') {
|
||||
error = t('app.notVerdande');
|
||||
return;
|
||||
}
|
||||
appSession.setServer(url);
|
||||
server = url;
|
||||
await enterServer();
|
||||
}
|
||||
|
||||
/** Asks the chosen server what it wants: sign-in, or — in a browser — setup. */
|
||||
async function enterServer() {
|
||||
try {
|
||||
const state = await api.setupState();
|
||||
mode = state.needs_setup ? 'setup' : 'login';
|
||||
} catch {
|
||||
mode = 'login';
|
||||
}
|
||||
}
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let name = $state('');
|
||||
@@ -37,6 +84,9 @@
|
||||
let passkeyReady = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Not in the app: a passkey is bound to the server's own domain, and the
|
||||
// app's pages are not on it. Offered there it would fail every time.
|
||||
if (native) return;
|
||||
available().then((ok) => (passkeyReady = ok));
|
||||
});
|
||||
|
||||
@@ -94,10 +144,11 @@
|
||||
mode = linkMode;
|
||||
return;
|
||||
}
|
||||
api
|
||||
.setupState()
|
||||
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
|
||||
.catch(() => (mode = 'login'));
|
||||
if (native && !appSession.server()) {
|
||||
mode = 'server';
|
||||
return;
|
||||
}
|
||||
enterServer();
|
||||
});
|
||||
|
||||
async function run(fn) {
|
||||
@@ -120,6 +171,10 @@
|
||||
|
||||
run(async () => {
|
||||
switch (mode) {
|
||||
case 'server':
|
||||
await chooseServer();
|
||||
break;
|
||||
|
||||
case 'setup':
|
||||
await api.setup({
|
||||
email,
|
||||
@@ -190,6 +245,34 @@
|
||||
|
||||
{#if mode === 'loading'}
|
||||
<p class="lede"> </p>
|
||||
{:else if mode === 'server'}
|
||||
<p class="lede">{t('app.serverPrompt')}</p>
|
||||
|
||||
<label>
|
||||
{t('app.server')}
|
||||
<input
|
||||
bind:value={server}
|
||||
use:focusOnMount
|
||||
type="url"
|
||||
inputmode="url"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
placeholder="verdande.example.dk"
|
||||
required
|
||||
/>
|
||||
<span class="hint">{t('app.serverHint')}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>{t('app.connect')}</button>
|
||||
{:else if mode === 'setup' && native}
|
||||
<!-- The first account starts a browser session, and the app has no use
|
||||
for a cookie. An instance this new is being set up by whoever runs
|
||||
it, at a desk; they do it there, and the app signs in after. -->
|
||||
<p class="lede">{t('app.setupInBrowser', { host: serverHost })}</p>
|
||||
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||
{t('app.changeServer')}
|
||||
</button>
|
||||
{:else if mode === 'setup'}
|
||||
<p class="lede">{t('auth.firstAccount')}</p>
|
||||
|
||||
@@ -303,6 +386,15 @@
|
||||
{:else}
|
||||
<p class="lede">{t('auth.signInPrompt')}</p>
|
||||
|
||||
{#if native}
|
||||
<p class="server-line">
|
||||
<span class="mono">{serverHost}</span>
|
||||
<button type="button" class="link" onclick={() => (mode = 'server')}>
|
||||
{t('app.changeServer')}
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
@@ -463,6 +555,23 @@
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Which server the app is signing in to, and the way back to choosing. */
|
||||
.server-line {
|
||||
display: flex;
|
||||
gap: var(--s2);
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.server-line .mono {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS, reminderOffsetLabel } from '$lib/when.js';
|
||||
import { REPEATS, reminderOffsetLabel, repeatLabel, shortDate } from '$lib/when.js';
|
||||
import { readList } from '$lib/pastelist.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
@@ -215,24 +215,83 @@
|
||||
|
||||
const patch = { content: trimmed };
|
||||
const took = [];
|
||||
// Det, der ikke kan stå i en PATCH: alarmen er sin egen række.
|
||||
let reminder = null;
|
||||
if (parsed && parsed.content && parsed.content !== trimmed) {
|
||||
patch.content = parsed.content;
|
||||
// Samme felter som hurtig tilføjelse læser, og med de navne serveren
|
||||
// sender dem under. Feltet hedder `recurrence_rule`; det læste en gang
|
||||
// `parsed.recurrence`, som aldrig findes, så "hver mandag" i en titel
|
||||
// forsvandt fra titlen og blev ingen steder.
|
||||
if (parsed.due_date) {
|
||||
patch.due_date = parsed.due_date;
|
||||
took.push(parsed.due_date);
|
||||
took.push(shortDate(parsed.due_date));
|
||||
}
|
||||
// Parseren fylder altid datoen ud, når den har læst et klokkeslæt, så
|
||||
// due_date er sat ovenfor, når due_time er.
|
||||
if (parsed.due_time) {
|
||||
patch.due_time = parsed.due_time;
|
||||
took.push(parsed.due_time);
|
||||
}
|
||||
if (parsed.priority && parsed.priority < 4) {
|
||||
patch.priority = parsed.priority;
|
||||
took.push(`P${parsed.priority}`);
|
||||
}
|
||||
if (parsed.recurrence) {
|
||||
patch.recurrence_rule = parsed.recurrence;
|
||||
took.push('gentagelse');
|
||||
if (parsed.recurrence_rule) {
|
||||
patch.recurrence_rule = parsed.recurrence_rule;
|
||||
took.push(repeatLabel(parsed.recurrence_rule, parsed.recurrence_text));
|
||||
}
|
||||
if (parsed.labels?.length) {
|
||||
patch.labels = [...new Set([...(task.labels ?? []), ...parsed.labels])];
|
||||
took.push(parsed.labels.map((l) => `@${l}`).join(' '));
|
||||
}
|
||||
if (parsed.project) {
|
||||
// Navnet slås op som hurtig tilføjelse gør: uden forskel på store og
|
||||
// små bogstaver. Et navn, der ikke findes, siges højt — det er klippet
|
||||
// ud af titlen, og uden en besked var ordet bare væk.
|
||||
const wanted = parsed.project.toLowerCase();
|
||||
const found = app.projects.find((p) => p.name.toLowerCase() === wanted);
|
||||
if (!found) {
|
||||
app.toast(
|
||||
t('task.unknownProject', {
|
||||
name: parsed.project,
|
||||
where: projectName(app.projectById(task.project_id))
|
||||
})
|
||||
);
|
||||
} else if (found.id !== task.project_id && !task.parent_id) {
|
||||
patch.project_id = found.id;
|
||||
took.push(`#${found.name}`);
|
||||
}
|
||||
}
|
||||
if (parsed.reminder_at) {
|
||||
// Vægurstid i læserens zone, ligesom feltet nedenfor giver den.
|
||||
reminder = { remind_at: new Date(parsed.reminder_at).toISOString() };
|
||||
} else if (parsed.reminder_offset_min !== undefined && parsed.reminder_offset_min !== null) {
|
||||
// En forskydning regnes fra klokkeslættet. Uden et ville rækken aldrig
|
||||
// ringe, og intet bagefter ville afsløre det.
|
||||
if (patch.due_time || task.due_datetime) {
|
||||
reminder = { offset_min: parsed.reminder_offset_min };
|
||||
} else {
|
||||
app.toast(t('task.alarmNeedsATime'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alarmen før rettelsen: får opgaven sit klokkeslæt i samme gemning, lægger
|
||||
// serveren standardalarmen på — medmindre opgaven allerede har en. Den,
|
||||
// der blev skrevet, skal være den, og ikke stå oven på standarden.
|
||||
if (reminder) {
|
||||
try {
|
||||
const created = await api.createReminder(task.id, reminder);
|
||||
reminders = [...reminders, created];
|
||||
took.push(
|
||||
reminder.offset_min !== undefined
|
||||
? reminderOffsetLabel(reminder.offset_min)
|
||||
: parsed.reminder_at.slice(11)
|
||||
);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
await save(patch);
|
||||
@@ -244,6 +303,8 @@
|
||||
if (took.length) {
|
||||
content = patch.content;
|
||||
if (patch.due_date) dueDate = patch.due_date;
|
||||
if (patch.due_time) dueTime = patch.due_time;
|
||||
if (patch.project_id) projectId = patch.project_id;
|
||||
if (patch.priority) priority = patch.priority;
|
||||
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
|
||||
if (patch.labels) labels = patch.labels.join(', ');
|
||||
|
||||
@@ -272,6 +272,9 @@ export const da = {
|
||||
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
|
||||
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
|
||||
// det, fordi en knap med en mikrofon på lover noget andet.
|
||||
'task.unknownProject': 'Der er intet projekt, der hedder "{name}". Opgaven ligger i {where}.',
|
||||
'task.unknownSection': 'Der er ingen sektion, der hedder "{name}" i {where}. Opgaven ligger uden sektion.',
|
||||
'task.subtaskKeepsProject': 'En underopgave ligger i sin opgaves projekt, {where} — "{name}" blev ikke brugt.',
|
||||
'task.alarmNeedsATime':
|
||||
'Opgaven er oprettet. Alarmen er ikke — "en time før" skal have et klokkeslæt at regne tilbage fra.',
|
||||
'task.speak': 'Tal en opgave ind',
|
||||
@@ -495,6 +498,20 @@ export const da = {
|
||||
'ai.keyNewHint': 'Gemmes på serveren og vises aldrig igen.',
|
||||
'tokens.lastUsed': 'sidst brugt {when}',
|
||||
'tokens.neverUsed': 'aldrig brugt',
|
||||
'connect.title': 'Forbind',
|
||||
'connect.heading': '{name} vil forbinde til din konto',
|
||||
'connect.what': 'Den kan læse og ændre dine opgaver og noter — med præcis dine rettigheder, ikke flere.',
|
||||
'connect.whatNotes': 'Den kan læse og ændre dine noter — med præcis dine rettigheder, ikke flere.',
|
||||
'tokens.signInHint': 'Til Claude og andre MCP-programmer behøver du ikke et token: tilføj adressen nedenfor som forbindelse, og log ind, når programmet beder om det.',
|
||||
'connect.account': 'Konto',
|
||||
'connect.sentTo': 'Sendes til',
|
||||
'connect.revoke': 'Du kan afbryde forbindelsen når som helst under Indstillinger → Tokens.',
|
||||
'connect.allow': 'Tillad',
|
||||
'connect.deny': 'Afvis',
|
||||
'connect.brokenTitle': 'Forbindelsen kan ikke laves',
|
||||
'connect.broken': 'Programmet, der sendte dig hertil, bad om noget, der ikke kan godkendes. Fjern forbindelsen i programmet, og tilføj den igen.',
|
||||
'connect.toApp': 'Til appen',
|
||||
'tokens.connected': 'forbundet med din konto',
|
||||
'tokens.expired': 'udløbet {when}',
|
||||
'tokens.expiresOn': 'udløber {when}',
|
||||
'tokens.revokeNamed': 'Tilbagekald {name}',
|
||||
@@ -736,6 +753,15 @@ export const da = {
|
||||
'auth.savePassword': 'Gem adgangskoden',
|
||||
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
|
||||
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
|
||||
'app.serverPrompt': 'Hvilken Verdande vil du logge ind på?',
|
||||
'app.server': 'Serveradresse',
|
||||
'app.serverHint': 'Den adresse, du åbner Verdande på i en browser.',
|
||||
'app.connect': 'Fortsæt',
|
||||
'app.serverInvalid': 'Det ligner ikke en adresse.',
|
||||
'app.serverUnreachable': 'Kunne ikke nå {host}. Tjek adressen og din forbindelse.',
|
||||
'app.notVerdande': 'Der svarer noget på den adresse, men det er ikke en Verdande.',
|
||||
'app.changeServer': 'Skift server',
|
||||
'app.setupInBrowser': '{host} har ingen konti endnu. Opret den første i en browser, og log så ind her.',
|
||||
'auth.signInPrompt': 'Log ind for at fortsætte.',
|
||||
'auth.signIn': 'Log ind',
|
||||
'auth.forgot': 'Glemt adgangskode?',
|
||||
|
||||
@@ -266,6 +266,9 @@ export const en = {
|
||||
'task.pastedAdd': 'Add {n} tasks',
|
||||
'task.pastedCancel': 'Cancel',
|
||||
'task.new': 'New task',
|
||||
'task.unknownProject': 'There is no project called "{name}". The task is in {where}.',
|
||||
'task.unknownSection': 'There is no section called "{name}" in {where}. The task has no section.',
|
||||
'task.subtaskKeepsProject': 'A sub-task stays in its task\'s project, {where} — "{name}" was not used.',
|
||||
'task.alarmNeedsATime':
|
||||
'The task is created. The alarm is not — "an hour before" needs a time to count back from.',
|
||||
'task.speak': 'Speak a task',
|
||||
@@ -489,6 +492,20 @@ export const en = {
|
||||
'ai.keyNewHint': 'Stored on the server and never shown again.',
|
||||
'tokens.lastUsed': 'last used {when}',
|
||||
'tokens.neverUsed': 'never used',
|
||||
'connect.title': 'Connect',
|
||||
'connect.heading': '{name} wants to connect to your account',
|
||||
'connect.what': 'It can read and change your tasks and notes — with exactly your permissions, no more.',
|
||||
'connect.whatNotes': 'It can read and change your notes — with exactly your permissions, no more.',
|
||||
'tokens.signInHint': 'Claude and other MCP programs do not need a token: add the address below as a connector, and sign in when it asks.',
|
||||
'connect.account': 'Account',
|
||||
'connect.sentTo': 'Sent to',
|
||||
'connect.revoke': 'You can disconnect it at any time under Settings → Tokens.',
|
||||
'connect.allow': 'Allow',
|
||||
'connect.deny': 'Deny',
|
||||
'connect.brokenTitle': 'This connection cannot be made',
|
||||
'connect.broken': 'The program that sent you here asked for something that cannot be approved. Remove the connection in that program and add it again.',
|
||||
'connect.toApp': 'To the app',
|
||||
'tokens.connected': 'connected with your account',
|
||||
'tokens.expired': 'expired {when}',
|
||||
'tokens.expiresOn': 'expires {when}',
|
||||
'tokens.revokeNamed': 'Revoke {name}',
|
||||
@@ -730,6 +747,15 @@ export const en = {
|
||||
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
|
||||
'auth.resetSent':
|
||||
'If that address has an account, a link is on its way. Check the spam filter too.',
|
||||
'app.serverPrompt': 'Which Verdande do you want to sign in to?',
|
||||
'app.server': 'Server address',
|
||||
'app.serverHint': 'The address you open Verdande at in a browser.',
|
||||
'app.connect': 'Continue',
|
||||
'app.serverInvalid': 'That does not look like an address.',
|
||||
'app.serverUnreachable': 'Could not reach {host}. Check the address and your connection.',
|
||||
'app.notVerdande': 'Something answers at that address, but it is not a Verdande.',
|
||||
'app.changeServer': 'Change server',
|
||||
'app.setupInBrowser': '{host} has no accounts yet. Create the first one in a browser, then sign in here.',
|
||||
'auth.signInPrompt': 'Sign in to continue.',
|
||||
'auth.signIn': 'Sign in',
|
||||
'auth.forgot': 'Forgotten your password?',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Pictures and files from the server, inside the iOS app.
|
||||
*
|
||||
* Attachments are addressed as `/api/v1/attachments/<id>` — in a note's text, in
|
||||
* a task's file list, in the export links — and in a browser that is the whole
|
||||
* story: the path is on the page's own origin and the cookie goes with it. In the
|
||||
* app neither holds. The path would resolve against capacitor://localhost, and an
|
||||
* `<img>` or a link cannot carry the bearer token even when pointed at the right
|
||||
* server.
|
||||
*
|
||||
* So in the app, and only there, this watches the page: an image whose source is
|
||||
* an API path is fetched with the token and shown from a blob, and a tap on a
|
||||
* link to an API path is answered the same way. Done once for the whole document
|
||||
* rather than in each component, because the paths come from many places — a
|
||||
* note's own markdown among them — and a component that forgot would show a
|
||||
* broken picture only in the app, where nobody runs the tests.
|
||||
*
|
||||
* The original path is kept in `data-src`. The note editor turns the page back
|
||||
* into markdown, and a blob address saved into a note is a picture that is gone
|
||||
* the next time anybody opens it; `richtext.js` reads `data-src` first.
|
||||
*/
|
||||
import { apiURL, authorize, isNativeApp } from './platform.js';
|
||||
|
||||
const API = '/api/v1/';
|
||||
const blobs = new Map();
|
||||
|
||||
async function blobFor(path) {
|
||||
if (!blobs.has(path)) {
|
||||
const promise = fetch(apiURL(path.slice(API.length - 1)), authorize({ method: 'GET' })).then(
|
||||
async (res) => {
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return URL.createObjectURL(await res.blob());
|
||||
}
|
||||
);
|
||||
// A failure is not cached: the next sight of the picture tries again.
|
||||
promise.catch(() => blobs.delete(path));
|
||||
blobs.set(path, promise);
|
||||
}
|
||||
return blobs.get(path);
|
||||
}
|
||||
|
||||
function adopt(img) {
|
||||
const path = img.getAttribute('src');
|
||||
// Our own blob address coming back through the observer is not an API path,
|
||||
// so it stops here; a new API path on an image already adopted is taken up.
|
||||
if (!path?.startsWith(API) || img.dataset.src === path) return;
|
||||
img.dataset.src = path;
|
||||
// Nothing until the picture is here, rather than a broken-image glyph.
|
||||
img.removeAttribute('src');
|
||||
blobFor(path)
|
||||
.then((url) => {
|
||||
if (img.dataset.src === path) img.src = url;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function scan(root) {
|
||||
if (root.nodeType !== 1) return;
|
||||
if (root.tagName === 'IMG') adopt(root);
|
||||
for (const img of root.querySelectorAll?.('img[src^="/api/v1/"]') ?? []) adopt(img);
|
||||
}
|
||||
|
||||
async function openLink(event) {
|
||||
const a = event.target.closest?.('a[href]');
|
||||
const path = a?.getAttribute('href');
|
||||
if (!path?.startsWith(API)) return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
window.open(await blobFor(path), '_blank');
|
||||
} catch {
|
||||
// Nothing to open; the request's own failure is what there is to say,
|
||||
// and there is no screen here to say it on.
|
||||
}
|
||||
}
|
||||
|
||||
/** Called once by the layout. A no-op in a browser. */
|
||||
export function installNativeMedia() {
|
||||
if (!isNativeApp() || typeof document === 'undefined') return;
|
||||
scan(document.body);
|
||||
new MutationObserver((records) => {
|
||||
for (const record of records) {
|
||||
if (record.type === 'attributes') adopt(record.target);
|
||||
else for (const node of record.addedNodes) scan(node);
|
||||
}
|
||||
}).observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src']
|
||||
});
|
||||
document.addEventListener('click', openLink, true);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Where the interface is running, and what that changes about reaching the server.
|
||||
*
|
||||
* In a browser the interface and the API share an origin: every path is relative
|
||||
* and the session is an httpOnly cookie no script can see. In the iOS app the
|
||||
* interface is bundled into the app and served from capacitor://localhost, and
|
||||
* the server is whichever one its owner typed in. So the app needs three things
|
||||
* a browser never does: the server's address, its own session token, and that
|
||||
* token on every request instead of a cookie.
|
||||
*
|
||||
* Everything that differs between the two lives here, so `api.js` and the socket
|
||||
* ask one question — `apiURL(path)`, `authorize(init)` — and the browser's answer
|
||||
* is exactly what it was before this file existed.
|
||||
*/
|
||||
|
||||
/** True inside the iOS app. Capacitor puts its bridge on `window` before any of
|
||||
* our code runs. */
|
||||
export function isNativeApp() {
|
||||
return !!globalThis.Capacitor?.isNativePlatform?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's stored settings: which server, and the session on it.
|
||||
*
|
||||
* localStorage for now, behind this one object, so the Keychain plugin can take
|
||||
* its place without anything else changing. The token is a session — whoever
|
||||
* holds it is signed in — and the Keychain is where a phone keeps those; until it
|
||||
* is wired in, this is a WKWebView's own storage, readable only by the app.
|
||||
*/
|
||||
const SERVER_KEY = 'verdande.app.server';
|
||||
const TOKEN_KEY = 'verdande.app.session';
|
||||
|
||||
function read(key) {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
try {
|
||||
if (value) localStorage.setItem(key, value);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage refused (private mode, quota). The app then asks again next
|
||||
// launch, which is the right failure: a token that seemed saved and was
|
||||
// not would look like being signed out for no reason.
|
||||
}
|
||||
}
|
||||
|
||||
export const appSession = {
|
||||
server: () => read(SERVER_KEY),
|
||||
setServer: (url) => write(SERVER_KEY, url),
|
||||
token: () => read(TOKEN_KEY),
|
||||
setToken: (token) => write(TOKEN_KEY, token),
|
||||
/** Signed out: the token goes, the server stays — signing in again is to the
|
||||
* same place nearly every time. */
|
||||
signOut: () => write(TOKEN_KEY, '')
|
||||
};
|
||||
|
||||
/** The address of an API path: relative in a browser, on the chosen server in
|
||||
* the app. */
|
||||
export function apiURL(path) {
|
||||
const base = isNativeApp() ? appSession.server() : '';
|
||||
return `${base}/api/v1${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in how a request proves who is asking.
|
||||
*
|
||||
* A browser sends its cookie, same-origin only. The app sends no cookie at all —
|
||||
* `omit`, so nothing the WebView picked up from somewhere rides along — and its
|
||||
* token as a bearer.
|
||||
*/
|
||||
export function authorize(init) {
|
||||
if (!isNativeApp()) {
|
||||
init.credentials = 'same-origin';
|
||||
return init;
|
||||
}
|
||||
init.credentials = 'omit';
|
||||
const token = appSession.token();
|
||||
if (token) {
|
||||
init.headers = { ...(init.headers ?? {}), Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
/** What the app calls itself in the list of signed-in devices. */
|
||||
export function deviceName() {
|
||||
const ua = navigator.userAgent;
|
||||
if (/iPad/.test(ua)) return 'iPad';
|
||||
if (/iPhone/.test(ua)) return 'iPhone';
|
||||
return 'Verdande-appen';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises what somebody typed as their server's address.
|
||||
*
|
||||
* "verdande.example.dk" becomes "https://verdande.example.dk"; a trailing slash
|
||||
* and a pasted "/api/v1" are dropped. Plain http is kept when typed on purpose,
|
||||
* because a self-hoster on their own network may have no certificate — iOS will
|
||||
* refuse it unless the app allows it, and that refusal should be the one they see.
|
||||
*/
|
||||
export function normaliseServer(input) {
|
||||
let s = (input ?? '').trim();
|
||||
if (!s) return '';
|
||||
if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
|
||||
try {
|
||||
const u = new URL(s);
|
||||
return `${u.protocol}//${u.host}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,16 @@
|
||||
|
||||
import { api } from './api.js';
|
||||
import { t } from './i18n.svelte.js';
|
||||
import { isNativeApp } from './platform.js';
|
||||
|
||||
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
|
||||
* can any browser on a page served over plain HTTP from a non-localhost host. */
|
||||
export function supported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
|
||||
// has no push service, and a subscription made there would go nowhere.
|
||||
!isNativeApp() &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
|
||||
@@ -267,7 +267,10 @@ function inlineToMarkdown(node) {
|
||||
// — og den første gemning ville tage hvert billede ud af noten uden at
|
||||
// nogen havde rørt det.
|
||||
case 'IMG':
|
||||
return ` ?? ''})`;
|
||||
// data-src first: in the iOS app the shown source is a blob made on this
|
||||
// device, and the path it was made from is what the note keeps. See
|
||||
// nativemedia.js.
|
||||
return ` || ''})`;
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return inner ? `**${inner}**` : '';
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
|
||||
import { api, ApiError, humanMessage } from './api.js';
|
||||
import { i18n, t } from './i18n.svelte.js';
|
||||
import { appSession, isNativeApp } from './platform.js';
|
||||
|
||||
/**
|
||||
* Where the live-sync socket is. A browser opens it on its own origin and the
|
||||
* cookie goes with the handshake. The app cannot put its token on a handshake,
|
||||
* so it asks for a one-use ticket first and opens the socket on its server with
|
||||
* that.
|
||||
*/
|
||||
async function socketURL() {
|
||||
if (!isNativeApp()) {
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${scheme}://${location.host}/api/v1/ws`;
|
||||
}
|
||||
const { ticket } = await api.wsTicket();
|
||||
const base = appSession.server().replace(/^http/, 'ws');
|
||||
return `${base}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
@@ -229,6 +246,14 @@ class AppState {
|
||||
async load() {
|
||||
this.loading = true;
|
||||
|
||||
// The app before it knows which server it belongs to: nothing to ask, and
|
||||
// the sign-in screen is where the address is asked for.
|
||||
if (isNativeApp() && !appSession.server()) {
|
||||
this.user = null;
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Døren, hvis dette vindue allerede er gået gennem den. Genindlæser nogen på
|
||||
// /noter inde i urd-appen, kommer de ikke forbi /urd igen, og uden den her
|
||||
// linje ville ansigtet falde af ved hvert F5.
|
||||
@@ -281,10 +306,23 @@ class AppState {
|
||||
|
||||
// --- realtime ---------------------------------------------------------------
|
||||
|
||||
connect() {
|
||||
async connect() {
|
||||
if (this.#socket) return;
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
|
||||
// Claimed before the await below, so two calls in the same moment cannot
|
||||
// both open one.
|
||||
this.#socket = 'opening';
|
||||
let url;
|
||||
try {
|
||||
url = await socketURL();
|
||||
} catch {
|
||||
// No ticket — offline, or signed out. Try again the way a dropped
|
||||
// socket does.
|
||||
this.#socket = null;
|
||||
setTimeout(() => this.connect(), this.#reconnectDelay);
|
||||
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
|
||||
return;
|
||||
}
|
||||
const socket = new WebSocket(url);
|
||||
this.#socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
@@ -691,6 +729,20 @@ class AppState {
|
||||
// slags påmindelse, hvis fejl intet bagefter afslører — der står ingen
|
||||
// række at savne, og man opdager den først, da den ikke ringede.
|
||||
if (task?.reminder_not_set) this.toast(t('task.alarmNeedsATime'));
|
||||
// Et "#navn" eller "/navn", der ikke ramte noget, er klippet ud af titlen
|
||||
// af parseren og lagt et andet sted af serveren. Uden de her linjer
|
||||
// forsvandt ordet bare, og opgaven lå et sted, ingen havde bedt om.
|
||||
const where = projectName(this.projectById(task?.project_id));
|
||||
if (task?.unknown_project) {
|
||||
this.toast(
|
||||
parentId
|
||||
? t('task.subtaskKeepsProject', { name: task.unknown_project, where })
|
||||
: t('task.unknownProject', { name: task.unknown_project, where })
|
||||
);
|
||||
}
|
||||
if (task?.unknown_section) {
|
||||
this.toast(t('task.unknownSection', { name: task.unknown_section, where }));
|
||||
}
|
||||
return task;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { setBadge } from '$lib/badge.js';
|
||||
import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js';
|
||||
import BeaconNotice from '$lib/components/BeaconNotice.svelte';
|
||||
import { installNativeMedia } from '$lib/nativemedia.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -51,13 +52,23 @@
|
||||
* screen. Without this the invite link showed a login form to a person who had
|
||||
* no account yet, and the reset link showed one to a person who had forgotten
|
||||
* the password it was asking for.
|
||||
*
|
||||
* `/forbind` is the third: the screen an MCP client sends somebody to. It
|
||||
* signs them in itself and then asks one question, and the shell around it
|
||||
* would be a way to wander off before answering it.
|
||||
*/
|
||||
let standalone = $derived(['/invite', '/reset'].includes($page.url.pathname));
|
||||
let standalone = $derived(['/invite', '/reset', '/forbind'].includes($page.url.pathname));
|
||||
|
||||
$effect(() => {
|
||||
app.load();
|
||||
});
|
||||
|
||||
// Pictures and downloads from the server, in the iOS app. A no-op in a
|
||||
// browser. See nativemedia.js.
|
||||
$effect(() => {
|
||||
installNativeMedia();
|
||||
});
|
||||
|
||||
/**
|
||||
* Hvilken dør dette vindue er inde ad.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script>
|
||||
/**
|
||||
* The approval screen an MCP client sends somebody to: "Claude wants to
|
||||
* connect to your account".
|
||||
*
|
||||
* The server's /oauth/authorize checks the request and hands the browser
|
||||
* here with the query unchanged. Signing in, if it is needed, is the ordinary
|
||||
* sign-in screen — passkeys and second factor included — and this page takes
|
||||
* over once there is somebody to ask.
|
||||
*
|
||||
* Two things are shown, and only one of them can be trusted. The name is
|
||||
* whatever the client called itself when it registered: any program can call
|
||||
* itself Claude. Where the browser goes afterwards is the address the client
|
||||
* registered, and that is what decides who receives the access — so it is
|
||||
* shown, and shown as plainly as the name.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
const query = typeof location === 'undefined' ? '' : location.search;
|
||||
|
||||
/** @type {{ client_name: string, redirect_to: string } | null} */
|
||||
let request = $state(null);
|
||||
let problem = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!app.user || request || problem) return;
|
||||
api
|
||||
.oauthRequest(query)
|
||||
.then((r) => (request = r))
|
||||
.catch((e) => (problem = e.message || humanMessage(e)));
|
||||
});
|
||||
|
||||
async function answer(approve) {
|
||||
busy = true;
|
||||
try {
|
||||
const { redirect_to } = await api.oauthApprove(query, approve);
|
||||
// The server only ever answers with an address the client registered,
|
||||
// and refuses the schemes a browser would run. Checked again here
|
||||
// because this line is what hands it to the browser.
|
||||
const scheme = new URL(redirect_to).protocol;
|
||||
if (['javascript:', 'data:', 'vbscript:', 'blob:'].includes(scheme)) throw new Error('bad redirect');
|
||||
location.assign(redirect_to);
|
||||
} catch (e) {
|
||||
busy = false;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('connect.title')} · {app.productSlug}</title></svelte:head>
|
||||
|
||||
{#if !app.user}
|
||||
<SignIn />
|
||||
{:else}
|
||||
<div class="screen">
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
<span class="slug">{app.productSlug}</span>
|
||||
</div>
|
||||
|
||||
{#if problem}
|
||||
<h1>{t('connect.brokenTitle')}</h1>
|
||||
<p class="lede">{t('connect.broken')}</p>
|
||||
<p class="detail mono">{problem}</p>
|
||||
<a class="link" href="/">{t('connect.toApp')}</a>
|
||||
{:else if !request}
|
||||
<p class="lede">…</p>
|
||||
{:else}
|
||||
<h1>{t('connect.heading', { name: request.client_name })}</h1>
|
||||
<p class="lede">{t(app.notesOnly ? 'connect.whatNotes' : 'connect.what')}</p>
|
||||
|
||||
<dl>
|
||||
<dt>{t('connect.account')}</dt>
|
||||
<dd>{app.user.name} · {app.user.email}</dd>
|
||||
<dt>{t('connect.sentTo')}</dt>
|
||||
<dd class="mono">{request.redirect_to}</dd>
|
||||
</dl>
|
||||
|
||||
<p class="hint">{t('connect.revoke')}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="allow" disabled={busy} onclick={() => answer(true)}>{t('connect.allow')}</button>
|
||||
<button class="deny" disabled={busy} onclick={() => answer(false)}>{t('connect.deny')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--s4);
|
||||
background: var(--ground);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rune {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.slug {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
padding: var(--s3) var(--s4);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--s2) var(--s4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.hint,
|
||||
.detail {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.allow {
|
||||
padding: var(--s3);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 550;
|
||||
transition: background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.allow:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.deny {
|
||||
padding: var(--s2) var(--s4);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -99,6 +99,10 @@
|
||||
<p class="hint">
|
||||
{t('tokens.hint')}
|
||||
</p>
|
||||
<!-- The way most people want: no token at all. The address is the same
|
||||
for everybody on this instance, so it can be shown without a secret. -->
|
||||
<p class="hint">{t('tokens.signInHint')}</p>
|
||||
<p class="mono value">{location.origin}/mcp</p>
|
||||
</header>
|
||||
|
||||
{#if fresh}
|
||||
@@ -175,6 +179,7 @@
|
||||
<span class="token-name">{token.name}</span>
|
||||
<span class="meta mono">
|
||||
{token.prefix}…
|
||||
{#if token.connected}· {t('tokens.connected')}{/if}
|
||||
{#if token.last_used_at}
|
||||
· {t('tokens.lastUsed', { when: date(token.last_used_at) })}
|
||||
{:else}
|
||||
|
||||
@@ -45,6 +45,17 @@ export default {
|
||||
*/
|
||||
version: {
|
||||
name: process.env.VERDANDE_VERSION ?? 'dev'
|
||||
},
|
||||
|
||||
/**
|
||||
* Not in the iOS app. WKWebView does not run service workers for an app's
|
||||
* own scheme, and the offline cache it would keep belongs to an interface
|
||||
* served by the instance — the app carries its interface inside itself.
|
||||
* `VERDANDE_TARGET=app` is set by the app's build; the browser build is
|
||||
* unchanged.
|
||||
*/
|
||||
serviceWorker: {
|
||||
register: process.env.VERDANDE_TARGET !== 'app'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user